diff --git a/Cargo.lock b/Cargo.lock index 9b15265c278..f6b05ed1439 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2202,12 +2202,23 @@ dependencies = [ "byteorder-lite", "color_quant", "gif", + "image-webp", "num-traits", "png", "zune-core", "zune-jpeg", ] +[[package]] +name = "image-webp" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e031e8e3d94711a9ccb5d6ea357439ef3dcbed361798bd4071dc4d9793fbe22f" +dependencies = [ + "byteorder-lite", + "quick-error", +] + [[package]] name = "images" version = "0.1.0" @@ -3165,6 +3176,12 @@ dependencies = [ "puffin_http", ] +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.30.0" diff --git a/crates/eframe/src/web/app_runner.rs b/crates/eframe/src/web/app_runner.rs index 13ad762874a..789e8e11d50 100644 --- a/crates/eframe/src/web/app_runner.rs +++ b/crates/eframe/src/web/app_runner.rs @@ -292,12 +292,15 @@ impl AppRunner { } fn handle_platform_output(&self, platform_output: egui::PlatformOutput) { + #![allow(deprecated)] + #[cfg(feature = "web_screen_reader")] if self.egui_ctx.options(|o| o.screen_reader) { super::screen_reader::speak(&platform_output.events_description()); } let egui::PlatformOutput { + commands, cursor_icon, open_url, copied_text, @@ -310,7 +313,19 @@ impl AppRunner { request_discard_reasons: _, // handled by `Context::run` } = platform_output; + for command in commands { + match command { + egui::OutputCommand::CopyText(text) => { + super::set_clipboard_text(&text); + } + egui::OutputCommand::OpenUrl(open_url) => { + super::open_url(&open_url.url, open_url.new_tab); + } + } + } + super::set_cursor_icon(cursor_icon); + if let Some(open) = open_url { super::open_url(&open.url, open.new_tab); } diff --git a/crates/egui-winit/src/lib.rs b/crates/egui-winit/src/lib.rs index 50ff2d31b4b..d6166a57bed 100644 --- a/crates/egui-winit/src/lib.rs +++ b/crates/egui-winit/src/lib.rs @@ -820,9 +820,11 @@ impl State { window: &Window, platform_output: egui::PlatformOutput, ) { + #![allow(deprecated)] profiling::function_scope!(); let egui::PlatformOutput { + commands, cursor_icon, open_url, copied_text, @@ -835,6 +837,17 @@ impl State { request_discard_reasons: _, // `egui::Context::run` handles this } = platform_output; + for command in commands { + match command { + egui::OutputCommand::CopyText(text) => { + self.clipboard.set(text); + } + egui::OutputCommand::OpenUrl(open_url) => { + open_url_in_browser(&open_url.url); + } + } + } + self.set_cursor_icon(window, cursor_icon); if let Some(open_url) = open_url { diff --git a/crates/egui/src/containers/frame.rs b/crates/egui/src/containers/frame.rs index 432356a870f..cf1f5a3acc5 100644 --- a/crates/egui/src/containers/frame.rs +++ b/crates/egui/src/containers/frame.rs @@ -20,7 +20,7 @@ use epaint::{Color32, Margin, Rect, Rounding, Shadow, Shape, Stroke}; /// /// ## Dynamic color /// If you want to change the color of the frame based on the response of -/// the widget, you needs to break it up into multiple steps: +/// the widget, you need to break it up into multiple steps: /// /// ``` /// # egui::__run_test_ui(|ui| { diff --git a/crates/egui/src/containers/window.rs b/crates/egui/src/containers/window.rs index b427fd75b83..e67a45a5dba 100644 --- a/crates/egui/src/containers/window.rs +++ b/crates/egui/src/containers/window.rs @@ -583,6 +583,7 @@ impl<'open> Window<'open> { outer_rect, frame_stroke, window_frame.rounding, + resize_interaction, ); // END FRAME -------------------------------- @@ -652,29 +653,30 @@ fn paint_resize_corner( outer_rect: Rect, stroke: impl Into, rounding: impl Into, + i: ResizeInteraction, ) { - let stroke = stroke.into(); + let inactive_stroke = stroke.into(); let rounding = rounding.into(); - let (corner, radius) = if possible.resize_right && possible.resize_bottom { - (Align2::RIGHT_BOTTOM, rounding.se) + let (corner, radius, corner_response) = if possible.resize_right && possible.resize_bottom { + (Align2::RIGHT_BOTTOM, rounding.se, i.right & i.bottom) } else if possible.resize_left && possible.resize_bottom { - (Align2::LEFT_BOTTOM, rounding.sw) + (Align2::LEFT_BOTTOM, rounding.sw, i.left & i.bottom) } else if possible.resize_left && possible.resize_top { - (Align2::LEFT_TOP, rounding.nw) + (Align2::LEFT_TOP, rounding.nw, i.left & i.top) } else if possible.resize_right && possible.resize_top { - (Align2::RIGHT_TOP, rounding.ne) + (Align2::RIGHT_TOP, rounding.ne, i.right & i.top) } else { // We're not in two directions, but it is still nice to tell the user // we're resizable by painting the resize corner in the expected place // (i.e. for windows only resizable in one direction): if possible.resize_right || possible.resize_bottom { - (Align2::RIGHT_BOTTOM, rounding.se) + (Align2::RIGHT_BOTTOM, rounding.se, i.right & i.bottom) } else if possible.resize_left || possible.resize_bottom { - (Align2::LEFT_BOTTOM, rounding.sw) + (Align2::LEFT_BOTTOM, rounding.sw, i.left & i.bottom) } else if possible.resize_left || possible.resize_top { - (Align2::LEFT_TOP, rounding.nw) + (Align2::LEFT_TOP, rounding.nw, i.left & i.top) } else if possible.resize_right || possible.resize_top { - (Align2::RIGHT_TOP, rounding.ne) + (Align2::RIGHT_TOP, rounding.ne, i.right & i.top) } else { return; } @@ -684,6 +686,14 @@ fn paint_resize_corner( let offset = ((2.0_f32.sqrt() * (1.0 + radius) - radius) * 45.0_f32.to_radians().cos()).max(2.0); + let stroke = if corner_response.drag { + ui.visuals().widgets.active.fg_stroke + } else if corner_response.hover { + ui.visuals().widgets.hovered.fg_stroke + } else { + inactive_stroke + }; + let corner_size = Vec2::splat(ui.visuals().resize_corner_size); let corner_rect = corner.align_size_within_rect(corner_size, outer_rect); let corner_rect = corner_rect.translate(-offset * corner.to_sign()); // move away from corner @@ -745,6 +755,17 @@ impl SideResponse { } } +impl std::ops::BitAnd for SideResponse { + type Output = Self; + + fn bitand(self, rhs: Self) -> Self::Output { + Self { + hover: self.hover && rhs.hover, + drag: self.drag && rhs.drag, + } + } +} + impl std::ops::BitOrAssign for SideResponse { fn bitor_assign(&mut self, rhs: Self) { *self = Self { @@ -850,7 +871,7 @@ fn resize_interaction( }; } - let is_dragging = |rect, id| { + let side_response = |rect, id| { let response = ctx.create_widget( WidgetRect { layer_id, @@ -873,6 +894,12 @@ fn resize_interaction( let side_grab_radius = ctx.style().interaction.resize_grab_radius_side; let corner_grab_radius = ctx.style().interaction.resize_grab_radius_corner; + let vetrtical_rect = |a: Pos2, b: Pos2| { + Rect::from_min_max(a, b).expand2(vec2(side_grab_radius, -corner_grab_radius)) + }; + let horizontal_rect = |a: Pos2, b: Pos2| { + Rect::from_min_max(a, b).expand2(vec2(-corner_grab_radius, side_grab_radius)) + }; let corner_rect = |center: Pos2| Rect::from_center_size(center, Vec2::splat(2.0 * corner_grab_radius)); @@ -883,59 +910,80 @@ fn resize_interaction( // Check sides first, so that corners are on top, covering the sides (i.e. corners have priority) if possible.resize_right { - let response = is_dragging( - Rect::from_min_max(rect.right_top(), rect.right_bottom()).expand(side_grab_radius), + let response = side_response( + vetrtical_rect(rect.right_top(), rect.right_bottom()), id.with("right"), ); right |= response; } if possible.resize_left { - let response = is_dragging( - Rect::from_min_max(rect.left_top(), rect.left_bottom()).expand(side_grab_radius), + let response = side_response( + vetrtical_rect(rect.left_top(), rect.left_bottom()), id.with("left"), ); left |= response; } if possible.resize_bottom { - let response = is_dragging( - Rect::from_min_max(rect.left_bottom(), rect.right_bottom()).expand(side_grab_radius), + let response = side_response( + horizontal_rect(rect.left_bottom(), rect.right_bottom()), id.with("bottom"), ); bottom |= response; } if possible.resize_top { - let response = is_dragging( - Rect::from_min_max(rect.left_top(), rect.right_top()).expand(side_grab_radius), + let response = side_response( + horizontal_rect(rect.left_top(), rect.right_top()), id.with("top"), ); top |= response; } // ---------------------------------------- - // Now check corners: - - if possible.resize_right && possible.resize_bottom { - let response = is_dragging(corner_rect(rect.right_bottom()), id.with("right_bottom")); - right |= response; - bottom |= response; + // Now check corners. + // We check any corner that has either side resizable, + // because we shrink the side resize handled by the corner width. + // Also, even if we can only change the width (or height) of a window, + // we show one of the corners as a grab-handle, so it makes sense that + // the whole corner is grabbable: + + if possible.resize_right || possible.resize_bottom { + let response = side_response(corner_rect(rect.right_bottom()), id.with("right_bottom")); + if possible.resize_right { + right |= response; + } + if possible.resize_bottom { + bottom |= response; + } } - if possible.resize_right && possible.resize_top { - let response = is_dragging(corner_rect(rect.right_top()), id.with("right_top")); - right |= response; - top |= response; + if possible.resize_right || possible.resize_top { + let response = side_response(corner_rect(rect.right_top()), id.with("right_top")); + if possible.resize_right { + right |= response; + } + if possible.resize_top { + top |= response; + } } - if possible.resize_left && possible.resize_bottom { - let response = is_dragging(corner_rect(rect.left_bottom()), id.with("left_bottom")); - left |= response; - bottom |= response; + if possible.resize_left || possible.resize_bottom { + let response = side_response(corner_rect(rect.left_bottom()), id.with("left_bottom")); + if possible.resize_left { + left |= response; + } + if possible.resize_bottom { + bottom |= response; + } } - if possible.resize_left && possible.resize_top { - let response = is_dragging(corner_rect(rect.left_top()), id.with("left_top")); - left |= response; - top |= response; + if possible.resize_left || possible.resize_top { + let response = side_response(corner_rect(rect.left_top()), id.with("left_top")); + if possible.resize_left { + left |= response; + } + if possible.resize_top { + top |= response; + } } let interaction = ResizeInteraction { diff --git a/crates/egui/src/context.rs b/crates/egui/src/context.rs index 70a52224671..6243857e92c 100644 --- a/crates/egui/src/context.rs +++ b/crates/egui/src/context.rs @@ -1419,6 +1419,12 @@ impl Context { self.output_mut(|o| o.cursor_icon = cursor_icon); } + /// Add a command to [`PlatformOutput::commands`], + /// for the integration to execute at the end of the frame. + pub fn send_cmd(&self, cmd: crate::OutputCommand) { + self.output_mut(|o| o.commands.push(cmd)); + } + /// Open an URL in a browser. /// /// Equivalent to: @@ -1428,24 +1434,16 @@ impl Context { /// ctx.output_mut(|o| o.open_url = Some(open_url)); /// ``` pub fn open_url(&self, open_url: crate::OpenUrl) { - self.output_mut(|o| o.open_url = Some(open_url)); + self.send_cmd(crate::OutputCommand::OpenUrl(open_url)); } /// Copy the given text to the system clipboard. /// - /// Empty strings are ignored. - /// /// Note that in wasm applications, the clipboard is only accessible in secure contexts (e.g., /// HTTPS or localhost). If this method is used outside of a secure context, it will log an /// error and do nothing. See . - /// - /// Equivalent to: - /// ``` - /// # let ctx = egui::Context::default(); - /// ctx.output_mut(|o| o.copied_text = "Copy this".to_owned()); - /// ``` pub fn copy_text(&self, text: String) { - self.output_mut(|o| o.copied_text = text); + self.send_cmd(crate::OutputCommand::CopyText(text)); } /// Format the given shortcut in a human-readable way (e.g. `Ctrl+Shift+X`). diff --git a/crates/egui/src/data/output.rs b/crates/egui/src/data/output.rs index a878bd5fd70..61e87bf618c 100644 --- a/crates/egui/src/data/output.rs +++ b/crates/egui/src/data/output.rs @@ -79,6 +79,21 @@ pub struct IMEOutput { pub cursor_rect: crate::Rect, } +/// Commands that the egui integration should execute at the end of a frame. +/// +/// Commands that are specific to a viewport should be put in [`crate::ViewportCommand`] instead. +#[derive(Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub enum OutputCommand { + /// Put this text in the system clipboard. + /// + /// This is often a response to [`crate::Event::Copy`] or [`crate::Event::Cut`]. + CopyText(String), + + /// Open this url in a browser. + OpenUrl(OpenUrl), +} + /// The non-rendering part of what egui emits each frame. /// /// You can access (and modify) this with [`crate::Context::output`]. @@ -87,10 +102,14 @@ pub struct IMEOutput { #[derive(Default, Clone, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] pub struct PlatformOutput { + /// Commands that the egui integration should execute at the end of a frame. + pub commands: Vec, + /// Set the cursor to this icon. pub cursor_icon: CursorIcon, /// If set, open this url. + #[deprecated = "Use `Context::open_url` instead"] pub open_url: Option, /// If set, put this text in the system clipboard. Ignore if empty. @@ -104,6 +123,7 @@ pub struct PlatformOutput { /// } /// # }); /// ``` + #[deprecated = "Use `Context::copy_text` instead"] pub copied_text: String, /// Events that may be useful to e.g. a screen reader. @@ -162,7 +182,10 @@ impl PlatformOutput { /// Add on new output. pub fn append(&mut self, newer: Self) { + #![allow(deprecated)] + let Self { + mut commands, cursor_icon, open_url, copied_text, @@ -175,6 +198,7 @@ impl PlatformOutput { mut request_discard_reasons, } = newer; + self.commands.append(&mut commands); self.cursor_icon = cursor_icon; if open_url.is_some() { self.open_url = open_url; @@ -213,7 +237,7 @@ impl PlatformOutput { /// What URL to open, and how. /// /// Use with [`crate::Context::open_url`]. -#[derive(Clone, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] pub struct OpenUrl { pub url: String, diff --git a/crates/egui/src/interaction.rs b/crates/egui/src/interaction.rs index 04f8f7dbf6f..22ec8ebc5a5 100644 --- a/crates/egui/src/interaction.rs +++ b/crates/egui/src/interaction.rs @@ -256,10 +256,6 @@ pub(crate) fn interact( // In that case we want to hover _both_ widgets, // otherwise we won't see tooltips for the label. // - // Because of how `Ui` work, we will often allocate the `Ui` rect - // _after_ adding the children in it (once we know the size it will occopy) - // so we will also have a lot of such `Ui` widgets rects covering almost any widget. - // // So: we want to hover _all_ widgets above the interactive widget (if any), // but none below it (an interactive widget stops the hover search). // @@ -275,8 +271,16 @@ pub(crate) fn interact( let mut hovered: IdSet = hits.click.iter().chain(&hits.drag).map(|w| w.id).collect(); for w in &hits.contains_pointer { - if top_interactive_order <= order(w.id).unwrap_or(0) { - hovered.insert(w.id); + let is_interactive = w.sense.click || w.sense.drag; + if is_interactive { + // The only interactive widgets we mark as hovered are the ones + // in `hits.click` and `hits.drag`! + } else { + let is_on_top_of_the_interactive_widget = + top_interactive_order <= order(w.id).unwrap_or(0); + if is_on_top_of_the_interactive_widget { + hovered.insert(w.id); + } } } diff --git a/crates/egui/src/lib.rs b/crates/egui/src/lib.rs index 1afaada95e1..b26f64d0b20 100644 --- a/crates/egui/src/lib.rs +++ b/crates/egui/src/lib.rs @@ -482,7 +482,8 @@ pub use self::{ data::{ input::*, output::{ - self, CursorIcon, FullOutput, OpenUrl, PlatformOutput, UserAttentionType, WidgetInfo, + self, CursorIcon, FullOutput, OpenUrl, OutputCommand, PlatformOutput, + UserAttentionType, WidgetInfo, }, Key, UserData, }, diff --git a/crates/egui/src/viewport.rs b/crates/egui/src/viewport.rs index 91cd12e2b60..d3f1c389d28 100644 --- a/crates/egui/src/viewport.rs +++ b/crates/egui/src/viewport.rs @@ -936,13 +936,16 @@ pub enum ResizeDirection { /// An output [viewport](crate::viewport)-command from egui to the backend, e.g. to change the window title or size. /// -/// You can send a [`ViewportCommand`] to the viewport with [`Context::send_viewport_cmd`]. +/// You can send a [`ViewportCommand`] to the viewport with [`Context::send_viewport_cmd`]. /// /// See [`crate::viewport`] for how to build new viewports (native windows). /// /// All coordinates are in logical points. /// -/// This is essentially a way to diff [`ViewportBuilder`]. +/// [`ViewportCommand`] is essentially a way to diff [`ViewportBuilder`]s. +/// +/// Only commands specific to a viewport are part of [`ViewportCommand`]. +/// Other commands should be put in [`crate::OutputCommand`]. #[derive(Clone, Debug, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] pub enum ViewportCommand { diff --git a/crates/egui/src/widgets/image.rs b/crates/egui/src/widgets/image.rs index 4cdfc5bf749..7380eaac26e 100644 --- a/crates/egui/src/widgets/image.rs +++ b/crates/egui/src/widgets/image.rs @@ -1,4 +1,4 @@ -use std::{borrow::Cow, sync::Arc, time::Duration}; +use std::{borrow::Cow, slice::Iter, sync::Arc, time::Duration}; use emath::{Float as _, Rot2}; use epaint::RectShape; @@ -286,12 +286,12 @@ impl<'a> Image<'a> { /// Returns the URI of the image. /// - /// For GIFs, returns the URI without the frame number. + /// For animated images, returns the URI without the frame number. #[inline] pub fn uri(&self) -> Option<&str> { let uri = self.source.uri()?; - if let Ok((gif_uri, _index)) = decode_gif_uri(uri) { + if let Ok((gif_uri, _index)) = decode_animated_image_uri(uri) { Some(gif_uri) } else { Some(uri) @@ -306,13 +306,15 @@ impl<'a> Image<'a> { #[inline] pub fn source(&'a self, ctx: &Context) -> ImageSource<'a> { match &self.source { - ImageSource::Uri(uri) if is_gif_uri(uri) => { - let frame_uri = encode_gif_uri(uri, gif_frame_index(ctx, uri)); + ImageSource::Uri(uri) if is_animated_image_uri(uri) => { + let frame_uri = + encode_animated_image_uri(uri, animated_image_frame_index(ctx, uri)); ImageSource::Uri(Cow::Owned(frame_uri)) } - ImageSource::Bytes { uri, bytes } if is_gif_uri(uri) || has_gif_magic_header(bytes) => { - let frame_uri = encode_gif_uri(uri, gif_frame_index(ctx, uri)); + ImageSource::Bytes { uri, bytes } if are_animated_image_bytes(bytes) => { + let frame_uri = + encode_animated_image_uri(uri, animated_image_frame_index(ctx, uri)); ctx.include_bytes(uri.clone(), bytes.clone()); ImageSource::Uri(Cow::Owned(frame_uri)) } @@ -796,57 +798,90 @@ pub fn paint_texture_at( } } -/// gif uris contain the uri & the frame that will be displayed -fn encode_gif_uri(uri: &str, frame_index: usize) -> String { +#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)] +/// Stores the durations between each frame of an animated image +pub struct FrameDurations(Arc>); + +impl FrameDurations { + pub fn new(durations: Vec) -> Self { + Self(Arc::new(durations)) + } + + pub fn all(&self) -> Iter<'_, Duration> { + self.0.iter() + } +} + +/// Animated image uris contain the uri & the frame that will be displayed +fn encode_animated_image_uri(uri: &str, frame_index: usize) -> String { format!("{uri}#{frame_index}") } -/// extracts uri and frame index +/// Extracts uri and frame index /// # Errors /// Will return `Err` if `uri` does not match pattern {uri}-{frame_index} -pub fn decode_gif_uri(uri: &str) -> Result<(&str, usize), String> { +pub fn decode_animated_image_uri(uri: &str) -> Result<(&str, usize), String> { let (uri, index) = uri .rsplit_once('#') .ok_or("Failed to find index separator '#'")?; - let index: usize = index - .parse() - .map_err(|_err| format!("Failed to parse gif frame index: {index:?} is not an integer"))?; + let index: usize = index.parse().map_err(|_err| { + format!("Failed to parse animated image frame index: {index:?} is not an integer") + })?; Ok((uri, index)) } -/// checks if uri is a gif file -fn is_gif_uri(uri: &str) -> bool { - uri.ends_with(".gif") || uri.contains(".gif#") -} - -/// checks if bytes are gifs -pub fn has_gif_magic_header(bytes: &[u8]) -> bool { - bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") -} +/// Calculates at which frame the animated image is +fn animated_image_frame_index(ctx: &Context, uri: &str) -> usize { + let now = ctx.input(|input| Duration::from_secs_f64(input.time)); -/// calculates at which frame the gif is -fn gif_frame_index(ctx: &Context, uri: &str) -> usize { - let now = ctx.input(|i| Duration::from_secs_f64(i.time)); + let durations: Option = ctx.data(|data| data.get_temp(Id::new(uri))); - let durations: Option = ctx.data(|data| data.get_temp(Id::new(uri))); if let Some(durations) = durations { - let frames: Duration = durations.0.iter().sum(); + let frames: Duration = durations.all().sum(); let pos_ms = now.as_millis() % frames.as_millis().max(1); + let mut cumulative_ms = 0; - for (i, duration) in durations.0.iter().enumerate() { + + for (index, duration) in durations.all().enumerate() { cumulative_ms += duration.as_millis(); + if pos_ms < cumulative_ms { let ms_until_next_frame = cumulative_ms - pos_ms; ctx.request_repaint_after(Duration::from_millis(ms_until_next_frame as u64)); - return i; + return index; } } + 0 } else { 0 } } -#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)] -/// Stores the durations between each frame of a gif -pub struct GifFrameDurations(pub Arc>); +/// Checks if uri is a gif file +fn is_gif_uri(uri: &str) -> bool { + uri.ends_with(".gif") || uri.contains(".gif#") +} + +/// Checks if bytes are gifs +pub fn has_gif_magic_header(bytes: &[u8]) -> bool { + bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") +} + +/// Checks if uri is a webp file +fn is_webp_uri(uri: &str) -> bool { + uri.ends_with(".webp") || uri.contains(".webp#") +} + +/// Checks if bytes are webp +pub fn has_webp_header(bytes: &[u8]) -> bool { + bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP" +} + +fn is_animated_image_uri(uri: &str) -> bool { + is_gif_uri(uri) || is_webp_uri(uri) +} + +fn are_animated_image_bytes(bytes: &[u8]) -> bool { + has_gif_magic_header(bytes) || has_webp_header(bytes) +} diff --git a/crates/egui/src/widgets/mod.rs b/crates/egui/src/widgets/mod.rs index 78e095aefdb..a4a40ec66f1 100644 --- a/crates/egui/src/widgets/mod.rs +++ b/crates/egui/src/widgets/mod.rs @@ -28,8 +28,8 @@ pub use self::{ drag_value::DragValue, hyperlink::{Hyperlink, Link}, image::{ - decode_gif_uri, has_gif_magic_header, paint_texture_at, GifFrameDurations, Image, ImageFit, - ImageOptions, ImageSize, ImageSource, + decode_animated_image_uri, has_gif_magic_header, has_webp_header, paint_texture_at, + FrameDurations, Image, ImageFit, ImageOptions, ImageSize, ImageSource, }, image_button::ImageButton, label::Label, diff --git a/crates/egui_demo_lib/src/demo/about.rs b/crates/egui_demo_lib/src/demo/about.rs index 4a3bfd45683..22fa70635c4 100644 --- a/crates/egui_demo_lib/src/demo/about.rs +++ b/crates/egui_demo_lib/src/demo/about.rs @@ -14,6 +14,7 @@ impl crate::Demo for About { .default_height(480.0) .open(open) .resizable([true, false]) + .scroll(false) .show(ctx, |ui| { use crate::View as _; self.ui(ui); @@ -36,11 +37,13 @@ impl crate::View for About { )); ui.label("egui is designed to be easy to use, portable, and fast."); - ui.add_space(12.0); // ui.separator(); + ui.add_space(12.0); + ui.heading("Immediate mode"); about_immediate_mode(ui); - ui.add_space(12.0); // ui.separator(); + ui.add_space(12.0); + ui.heading("Links"); links(ui); @@ -50,7 +53,10 @@ impl crate::View for About { ui.spacing_mut().item_spacing.x = 0.0; ui.label("egui development is sponsored by "); ui.hyperlink_to("Rerun.io", "https://www.rerun.io/"); - ui.label(", a startup building an SDK for visualizing streams of multimodal data."); + ui.label(", a startup building an SDK for visualizing streams of multimodal data. "); + ui.label("For an example of a real-world egui app, see "); + ui.hyperlink_to("rerun.io/viewer", "https://www.rerun.io/viewer"); + ui.label(" (runs in your browser)."); }); ui.add_space(12.0); @@ -94,12 +100,12 @@ fn about_immediate_mode(ui: &mut egui::Ui) { fn links(ui: &mut egui::Ui) { use egui::special_emojis::{GITHUB, TWITTER}; ui.hyperlink_to( - format!("{GITHUB} egui on GitHub"), + format!("{GITHUB} github.com/emilk/egui"), "https://github.com/emilk/egui", ); ui.hyperlink_to( format!("{TWITTER} @ernerfeldt"), "https://twitter.com/ernerfeldt", ); - ui.hyperlink_to("egui documentation", "https://docs.rs/egui/"); + ui.hyperlink_to("📓 egui documentation", "https://docs.rs/egui/"); } diff --git a/crates/egui_demo_lib/src/demo/code_example.rs b/crates/egui_demo_lib/src/demo/code_example.rs index 3db90ad3e34..9094c19a71d 100644 --- a/crates/egui_demo_lib/src/demo/code_example.rs +++ b/crates/egui_demo_lib/src/demo/code_example.rs @@ -84,9 +84,8 @@ impl CodeExample { ui.horizontal(|ui| { let font_id = egui::TextStyle::Monospace.resolve(ui.style()); - let indentation = 8.0 * ui.fonts(|f| f.glyph_width(&font_id, ' ')); - let item_spacing = ui.spacing_mut().item_spacing; - ui.add_space(indentation - item_spacing.x); + let indentation = 2.0 * 4.0 * ui.fonts(|f| f.glyph_width(&font_id, ' ')); + ui.add_space(indentation); egui::Grid::new("code_samples") .striped(true) @@ -112,7 +111,7 @@ impl crate::Demo for CodeExample { .min_width(375.0) .default_size([390.0, 500.0]) .scroll(false) - .resizable([true, false]) + .resizable([true, false]) // resizable so we can shrink if the text edit grows .show(ctx, |ui| self.ui(ui)); } } @@ -120,7 +119,7 @@ impl crate::Demo for CodeExample { impl crate::View for CodeExample { fn ui(&mut self, ui: &mut egui::Ui) { ui.scope(|ui| { - ui.spacing_mut().item_spacing = egui::vec2(8.0, 8.0); + ui.spacing_mut().item_spacing = egui::vec2(8.0, 6.0); self.code(ui); }); diff --git a/crates/egui_demo_lib/src/demo/demo_app_windows.rs b/crates/egui_demo_lib/src/demo/demo_app_windows.rs index 2cfcdfaeeba..cee322c0f54 100644 --- a/crates/egui_demo_lib/src/demo/demo_app_windows.rs +++ b/crates/egui_demo_lib/src/demo/demo_app_windows.rs @@ -1,6 +1,6 @@ use std::collections::BTreeSet; -use egui::{Context, Modifiers, NumExt as _, ScrollArea, Ui}; +use egui::{Context, Modifiers, ScrollArea, Ui}; use super::About; use crate::is_mobile; @@ -9,73 +9,17 @@ use crate::View; // ---------------------------------------------------------------------------- -#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] -#[cfg_attr(feature = "serde", serde(default))] -struct Demos { - #[cfg_attr(feature = "serde", serde(skip))] +struct DemoGroup { demos: Vec>, - - open: BTreeSet, } -impl Default for Demos { - fn default() -> Self { - Self::from_demos(vec![ - Box::::default(), - Box::::default(), - Box::::default(), - Box::::default(), - Box::::default(), - Box::::default(), - Box::::default(), - Box::::default(), - Box::::default(), - Box::::default(), - Box::::default(), - Box::::default(), - Box::::default(), - Box::::default(), - Box::::default(), - Box::::default(), - Box::::default(), - Box::::default(), - Box::::default(), - Box::::default(), - Box::::default(), - Box::::default(), - Box::::default(), - Box::::default(), - Box::::default(), - Box::::default(), - Box::::default(), - Box::::default(), - ]) +impl DemoGroup { + pub fn new(demos: Vec>) -> Self { + Self { demos } } -} - -impl Demos { - pub fn from_demos(demos: Vec>) -> Self { - let mut open = BTreeSet::new(); - - // Explains egui very well - open.insert( - super::code_example::CodeExample::default() - .name() - .to_owned(), - ); - // Shows off the features - open.insert( - super::widget_gallery::WidgetGallery::default() - .name() - .to_owned(), - ); - - Self { demos, open } - } - - pub fn checkboxes(&mut self, ui: &mut Ui) { - let Self { demos, open } = self; + pub fn checkboxes(&mut self, ui: &mut Ui, open: &mut BTreeSet) { + let Self { demos } = self; for demo in demos { if demo.is_enabled(ui.ctx()) { let mut is_open = open.contains(demo.name()); @@ -85,8 +29,8 @@ impl Demos { } } - pub fn windows(&mut self, ctx: &Context) { - let Self { demos, open } = self; + pub fn windows(&mut self, ctx: &Context, open: &mut BTreeSet) { + let Self { demos } = self; for demo in demos { let mut is_open = open.contains(demo.name()); demo.show(ctx, &mut is_open); @@ -95,72 +39,104 @@ impl Demos { } } -// ---------------------------------------------------------------------------- +fn set_open(open: &mut BTreeSet, key: &'static str, is_open: bool) { + if is_open { + if !open.contains(key) { + open.insert(key.to_owned()); + } + } else { + open.remove(key); + } +} -#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] -#[cfg_attr(feature = "serde", serde(default))] -struct Tests { - #[cfg_attr(feature = "serde", serde(skip))] - demos: Vec>, +// ---------------------------------------------------------------------------- - open: BTreeSet, +pub struct DemoGroups { + about: About, + demos: DemoGroup, + tests: DemoGroup, } -impl Default for Tests { +impl Default for DemoGroups { fn default() -> Self { - Self::from_demos(vec![ - Box::::default(), - Box::::default(), - Box::::default(), - Box::::default(), - Box::::default(), - Box::::default(), - Box::::default(), - Box::::default(), - ]) + Self { + about: About::default(), + demos: DemoGroup::new(vec![ + Box::::default(), + Box::::default(), + Box::::default(), + Box::::default(), + Box::::default(), + Box::::default(), + Box::::default(), + Box::::default(), + Box::::default(), + Box::::default(), + Box::::default(), + Box::::default(), + Box::::default(), + Box::::default(), + Box::::default(), + Box::::default(), + Box::::default(), + Box::::default(), + Box::::default(), + Box::::default(), + Box::::default(), + Box::::default(), + Box::::default(), + Box::::default(), + Box::::default(), + Box::::default(), + Box::::default(), + Box::::default(), + ]), + tests: DemoGroup::new(vec![ + Box::::default(), + Box::::default(), + Box::::default(), + Box::::default(), + Box::::default(), + Box::::default(), + Box::::default(), + Box::::default(), + ]), + } } } -impl Tests { - pub fn from_demos(demos: Vec>) -> Self { - let mut open = BTreeSet::new(); - open.insert( - super::widget_gallery::WidgetGallery::default() - .name() - .to_owned(), - ); - - Self { demos, open } - } - - pub fn checkboxes(&mut self, ui: &mut Ui) { - let Self { demos, open } = self; - for demo in demos { - let mut is_open = open.contains(demo.name()); - ui.toggle_value(&mut is_open, demo.name()); - set_open(open, demo.name(), is_open); - } - } +impl DemoGroups { + pub fn checkboxes(&mut self, ui: &mut Ui, open: &mut BTreeSet) { + let Self { + about, + demos, + tests, + } = self; - pub fn windows(&mut self, ctx: &Context) { - let Self { demos, open } = self; - for demo in demos { - let mut is_open = open.contains(demo.name()); - demo.show(ctx, &mut is_open); - set_open(open, demo.name(), is_open); + { + let mut is_open = open.contains(about.name()); + ui.toggle_value(&mut is_open, about.name()); + set_open(open, about.name(), is_open); } + ui.separator(); + demos.checkboxes(ui, open); + ui.separator(); + tests.checkboxes(ui, open); } -} -// ---------------------------------------------------------------------------- - -fn set_open(open: &mut BTreeSet, key: &'static str, is_open: bool) { - if is_open { - if !open.contains(key) { - open.insert(key.to_owned()); + pub fn windows(&mut self, ctx: &Context, open: &mut BTreeSet) { + let Self { + about, + demos, + tests, + } = self; + { + let mut is_open = open.contains(about.name()); + about.show(ctx, &mut is_open); + set_open(open, about.name(), is_open); } - } else { - open.remove(key); + demos.windows(ctx, open); + tests.windows(ctx, open); } } @@ -170,19 +146,36 @@ fn set_open(open: &mut BTreeSet, key: &'static str, is_open: bool) { #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[cfg_attr(feature = "serde", serde(default))] pub struct DemoWindows { - about_is_open: bool, - about: About, - demos: Demos, - tests: Tests, + #[cfg_attr(feature = "serde", serde(skip))] + groups: DemoGroups, + + open: BTreeSet, } impl Default for DemoWindows { fn default() -> Self { + let mut open = BTreeSet::new(); + + // Explains egui very well + set_open(&mut open, About::default().name(), true); + + // Explains egui very well + set_open( + &mut open, + super::code_example::CodeExample::default().name(), + true, + ); + + // Shows off the features + set_open( + &mut open, + super::widget_gallery::WidgetGallery::default().name(), + true, + ); + Self { - about_is_open: true, - about: Default::default(), - demos: Default::default(), - tests: Default::default(), + groups: Default::default(), + open, } } } @@ -197,36 +190,35 @@ impl DemoWindows { } } - fn mobile_ui(&mut self, ctx: &Context) { - if self.about_is_open { - let screen_size = ctx.input(|i| i.screen_rect.size()); - let default_width = (screen_size.x - 32.0).at_most(400.0); + fn about_is_open(&self) -> bool { + self.open.contains(About::default().name()) + } + fn mobile_ui(&mut self, ctx: &Context) { + if self.about_is_open() { let mut close = false; - egui::Window::new(self.about.name()) - .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) - .default_width(default_width) - .default_height(ctx.available_rect().height() - 46.0) - .vscroll(true) - .open(&mut self.about_is_open) - .resizable(false) - .collapsible(false) - .show(ctx, |ui| { - self.about.ui(ui); - ui.add_space(12.0); - ui.vertical_centered_justified(|ui| { - if ui - .button(egui::RichText::new("Continue to the demo!").size(20.0)) - .clicked() - { - close = true; - } + egui::CentralPanel::default().show(ctx, |ui| { + egui::ScrollArea::vertical() + .auto_shrink(false) + .show(ui, |ui| { + self.groups.about.ui(ui); + ui.add_space(12.0); + ui.vertical_centered_justified(|ui| { + if ui + .button(egui::RichText::new("Continue to the demo!").size(20.0)) + .clicked() + { + close = true; + } + }); }); - }); - self.about_is_open &= !close; + }); + if close { + set_open(&mut self.open, About::default().name(), false); + } } else { self.mobile_top_bar(ctx); - self.show_windows(ctx); + self.groups.windows(ctx, &mut self.open); } } @@ -292,27 +284,14 @@ impl DemoWindows { }); }); - self.show_windows(ctx); - } - - /// Show the open windows. - fn show_windows(&mut self, ctx: &Context) { - self.about.show(ctx, &mut self.about_is_open); - self.demos.windows(ctx); - self.tests.windows(ctx); + self.groups.windows(ctx, &mut self.open); } fn demo_list_ui(&mut self, ui: &mut egui::Ui) { ScrollArea::vertical().show(ui, |ui| { ui.with_layout(egui::Layout::top_down_justified(egui::Align::LEFT), |ui| { - ui.toggle_value(&mut self.about_is_open, self.about.name()); - - ui.separator(); - self.demos.checkboxes(ui); - ui.separator(); - self.tests.checkboxes(ui); + self.groups.checkboxes(ui, &mut self.open); ui.separator(); - if ui.button("Organize windows").clicked() { ui.ctx().memory_mut(|mem| mem.reset_areas()); } @@ -382,29 +361,29 @@ fn file_menu_button(ui: &mut Ui) { #[cfg(test)] mod tests { - use crate::demo::demo_app_windows::Demos; + use crate::{demo::demo_app_windows::DemoGroups, Demo}; use egui::Vec2; use egui_kittest::kittest::Queryable; use egui_kittest::{Harness, SnapshotOptions}; #[test] fn demos_should_match_snapshot() { - let demos = Demos::default(); + let demos = DemoGroups::default().demos; let mut errors = Vec::new(); for mut demo in demos.demos { + // Widget Gallery needs to be customized (to set a specific date) and has its own test + if demo.name() == crate::WidgetGallery::default().name() { + continue; + } + // Remove the emoji from the demo name let name = demo .name() .split_once(' ') .map_or(demo.name(), |(_, name)| name); - // Widget Gallery needs to be customized (to set a specific date) and has its own test - if name == "Widget Gallery" { - continue; - } - let mut harness = Harness::new(|ctx| { demo.show(ctx, &mut true); }); diff --git a/crates/egui_demo_lib/src/demo/font_book.rs b/crates/egui_demo_lib/src/demo/font_book.rs index e2310f8a1e3..352bc0273a7 100644 --- a/crates/egui_demo_lib/src/demo/font_book.rs +++ b/crates/egui_demo_lib/src/demo/font_book.rs @@ -85,7 +85,7 @@ impl crate::View for FontBook { ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing = egui::Vec2::splat(2.0); - for (&chr, glyph_info) in available_glyphs { + for (&chr, glyph_info) in available_glyphs.iter() { if filter.is_empty() || glyph_info.name.contains(filter) || *filter == chr.to_string() @@ -96,13 +96,9 @@ impl crate::View for FontBook { .frame(false); let tooltip_ui = |ui: &mut egui::Ui| { - ui.label( - egui::RichText::new(chr.to_string()).font(self.font_id.clone()), - ); - ui.label(format!( - "{}\nU+{:X}\n\nFound in: {:?}\n\nClick to copy", - glyph_info.name, chr as u32, glyph_info.fonts - )); + let font_id = self.font_id.clone(); + + char_info_ui(ui, chr, glyph_info, font_id); }; if ui.add(button).on_hover_ui(tooltip_ui).clicked() { @@ -115,6 +111,35 @@ impl crate::View for FontBook { } } +fn char_info_ui(ui: &mut egui::Ui, chr: char, glyph_info: &GlyphInfo, font_id: egui::FontId) { + let resp = ui.label(egui::RichText::new(chr.to_string()).font(font_id)); + + egui::Grid::new("char_info") + .num_columns(2) + .striped(true) + .show(ui, |ui| { + ui.label("Name"); + ui.label(glyph_info.name.clone()); + ui.end_row(); + + ui.label("Hex"); + ui.label(format!("{:X}", chr as u32)); + ui.end_row(); + + ui.label("Width"); + ui.label(format!("{:.1} pts", resp.rect.width())); + ui.end_row(); + + ui.label("Fonts"); + ui.label( + format!("{:?}", glyph_info.fonts) + .trim_start_matches('[') + .trim_end_matches(']'), + ); + ui.end_row(); + }); +} + fn available_characters(ui: &egui::Ui, family: egui::FontFamily) -> BTreeMap { ui.fonts(|f| { f.lock() diff --git a/crates/egui_demo_lib/src/demo/widget_gallery.rs b/crates/egui_demo_lib/src/demo/widget_gallery.rs index b69d0f1c8d2..d473d4b9d0e 100644 --- a/crates/egui_demo_lib/src/demo/widget_gallery.rs +++ b/crates/egui_demo_lib/src/demo/widget_gallery.rs @@ -50,7 +50,7 @@ impl crate::Demo for WidgetGallery { fn show(&mut self, ctx: &egui::Context, open: &mut bool) { egui::Window::new(self.name()) .open(open) - .resizable([true, false]) + .resizable([true, false]) // resizable so we can shrink if the text edit grows .default_width(280.0) .show(ctx, |ui| { use crate::View as _; @@ -254,7 +254,7 @@ impl WidgetGallery { ui.end_row(); ui.hyperlink_to( - "Custom widget:", + "Custom widget", super::toggle_switch::url_to_file_source_code(), ); ui.add(super::toggle_switch::toggle(boolean)).on_hover_text( @@ -274,10 +274,9 @@ fn doc_link_label_with_crate<'a>( title: &'a str, search_term: &'a str, ) -> impl egui::Widget + 'a { - let label = format!("{title}:"); let url = format!("https://docs.rs/{crate_name}?search={search_term}"); move |ui: &mut egui::Ui| { - ui.hyperlink_to(label, url).on_hover_ui(|ui| { + ui.hyperlink_to(title, url).on_hover_ui(|ui| { ui.horizontal_wrapped(|ui| { ui.label("Search egui docs for"); ui.code(search_term); diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Code Example.png b/crates/egui_demo_lib/tests/snapshots/demos/Code Example.png index 04fa9ba3c55..b835030147b 100644 --- a/crates/egui_demo_lib/tests/snapshots/demos/Code Example.png +++ b/crates/egui_demo_lib/tests/snapshots/demos/Code Example.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e640606207265b4f040f793b0ffb989504b6a98b89e95e77a9a9d3e3abc9327a -size 80933 +oid sha256:37ba383a2ba7f00f8064d21203c22f9de2f688ea4fbd45d400c979187686cf33 +size 80861 diff --git a/crates/egui_demo_lib/tests/snapshots/widget_gallery.png b/crates/egui_demo_lib/tests/snapshots/widget_gallery.png index 51596247499..6f06e7727f6 100644 --- a/crates/egui_demo_lib/tests/snapshots/widget_gallery.png +++ b/crates/egui_demo_lib/tests/snapshots/widget_gallery.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d122b1a995e691b5049c57d65c9f222a5f1639b1e4f6f96f91823444339693cc -size 160540 +oid sha256:b3dc1bf9a59007a6ad0fb66a345d6cf272bd8bdcd26b10dbf411c1280e62b6fc +size 158285 diff --git a/crates/egui_extras/Cargo.toml b/crates/egui_extras/Cargo.toml index 41fbcf0a462..89465f6d130 100644 --- a/crates/egui_extras/Cargo.toml +++ b/crates/egui_extras/Cargo.toml @@ -31,7 +31,7 @@ rustdoc-args = ["--generate-link-to-definition"] default = ["dep:mime_guess2"] ## Shorthand for enabling the different types of image loaders (`file`, `http`, `image`, `svg`). -all_loaders = ["file", "http", "image", "svg", "gif"] +all_loaders = ["file", "http", "image", "svg", "gif", "webp"] ## Enable [`DatePickerButton`] widget. datepicker = ["chrono"] @@ -42,6 +42,9 @@ file = ["dep:mime_guess2"] ## Support loading gif images. gif = ["image", "image/gif"] +## Support loading webp images. +webp = ["image", "image/webp"] + ## Add support for loading images via HTTP. http = ["dep:ehttp"] diff --git a/crates/egui_extras/src/loaders.rs b/crates/egui_extras/src/loaders.rs index 02683e442e7..03b1abfc9f2 100644 --- a/crates/egui_extras/src/loaders.rs +++ b/crates/egui_extras/src/loaders.rs @@ -84,6 +84,12 @@ pub fn install_image_loaders(ctx: &egui::Context) { log::trace!("installed GifLoader"); } + #[cfg(feature = "webp")] + if !ctx.is_loader_installed(self::webp_loader::WebPLoader::ID) { + ctx.add_image_loader(std::sync::Arc::new(self::webp_loader::WebPLoader::default())); + log::trace!("installed WebPLoader"); + } + #[cfg(feature = "svg")] if !ctx.is_loader_installed(self::svg_loader::SvgLoader::ID) { ctx.add_image_loader(std::sync::Arc::new(self::svg_loader::SvgLoader::default())); @@ -113,3 +119,5 @@ mod gif_loader; mod image_loader; #[cfg(feature = "svg")] mod svg_loader; +#[cfg(feature = "webp")] +mod webp_loader; diff --git a/crates/egui_extras/src/loaders/gif_loader.rs b/crates/egui_extras/src/loaders/gif_loader.rs index 1c20135150c..a92cbc33e41 100644 --- a/crates/egui_extras/src/loaders/gif_loader.rs +++ b/crates/egui_extras/src/loaders/gif_loader.rs @@ -1,9 +1,9 @@ use ahash::HashMap; use egui::{ - decode_gif_uri, has_gif_magic_header, + decode_animated_image_uri, has_gif_magic_header, load::{BytesPoll, ImageLoadResult, ImageLoader, ImagePoll, LoadError, SizeHint}, mutex::Mutex, - ColorImage, GifFrameDurations, Id, + ColorImage, FrameDurations, Id, }; use image::AnimationDecoder as _; use std::{io::Cursor, mem::size_of, sync::Arc, time::Duration}; @@ -12,7 +12,7 @@ use std::{io::Cursor, mem::size_of, sync::Arc, time::Duration}; #[derive(Debug, Clone)] pub struct AnimatedImage { frames: Vec>, - frame_durations: GifFrameDurations, + frame_durations: FrameDurations, } impl AnimatedImage { @@ -35,7 +35,7 @@ impl AnimatedImage { } Ok(Self { frames: images, - frame_durations: GifFrameDurations(Arc::new(durations)), + frame_durations: FrameDurations::new(durations), }) } } @@ -75,7 +75,7 @@ impl ImageLoader for GifLoader { fn load(&self, ctx: &egui::Context, frame_uri: &str, _: SizeHint) -> ImageLoadResult { let (image_uri, frame_index) = - decode_gif_uri(frame_uri).map_err(|_err| LoadError::NotSupported)?; + decode_animated_image_uri(frame_uri).map_err(|_err| LoadError::NotSupported)?; let mut cache = self.cache.lock(); if let Some(entry) = cache.get(image_uri).cloned() { match entry { diff --git a/crates/egui_extras/src/loaders/image_loader.rs b/crates/egui_extras/src/loaders/image_loader.rs index 4c1a846e26b..171e56170db 100644 --- a/crates/egui_extras/src/loaders/image_loader.rs +++ b/crates/egui_extras/src/loaders/image_loader.rs @@ -19,7 +19,10 @@ impl ImageCrateLoader { } fn is_supported_uri(uri: &str) -> bool { - let Some(ext) = Path::new(uri).extension().and_then(|ext| ext.to_str()) else { + let Some(ext) = Path::new(uri) + .extension() + .and_then(|ext| ext.to_str().map(|ext| ext.to_lowercase())) + else { // `true` because if there's no extension, assume that we support it return true; }; diff --git a/crates/egui_extras/src/loaders/webp_loader.rs b/crates/egui_extras/src/loaders/webp_loader.rs new file mode 100644 index 00000000000..bb042093b17 --- /dev/null +++ b/crates/egui_extras/src/loaders/webp_loader.rs @@ -0,0 +1,186 @@ +use ahash::HashMap; +use egui::{ + decode_animated_image_uri, has_webp_header, + load::{BytesPoll, ImageLoadResult, ImageLoader, ImagePoll, LoadError, SizeHint}, + mutex::Mutex, + ColorImage, FrameDurations, Id, +}; +use image::{codecs::webp::WebPDecoder, AnimationDecoder as _, ImageDecoder, Rgba}; +use std::{io::Cursor, mem::size_of, sync::Arc, time::Duration}; + +#[derive(Clone)] +enum WebP { + Static(Arc), + Animated(AnimatedImage), +} + +impl WebP { + fn load(data: &[u8]) -> Result { + let mut decoder = WebPDecoder::new(Cursor::new(data)) + .map_err(|error| format!("WebP decode failure ({error})"))?; + + if decoder.has_animation() { + decoder + .set_background_color(Rgba([0, 0, 0, 0])) + .map_err(|error| { + format!("Failure to set default background color for animated WebP ({error})") + })?; + + let mut images = vec![]; + let mut durations = vec![]; + + for frame in decoder.into_frames() { + let frame = + frame.map_err(|error| format!("WebP frame decode failure ({error})"))?; + let image = frame.buffer(); + let pixels = image.as_flat_samples(); + + images.push(Arc::new(ColorImage::from_rgba_unmultiplied( + [image.width() as usize, image.height() as usize], + pixels.as_slice(), + ))); + + let delay: Duration = frame.delay().into(); + durations.push(delay); + } + Ok(Self::Animated(AnimatedImage { + frames: images, + frame_durations: FrameDurations::new(durations), + })) + } else { + let (width, height) = decoder.dimensions(); + let size = decoder.total_bytes() as usize; + + let mut data = vec![0; size]; + decoder + .read_image(&mut data) + .map_err(|error| format!("WebP image read failure ({error})"))?; + + let image = + ColorImage::from_rgba_unmultiplied([width as usize, height as usize], &data); + + Ok(Self::Static(Arc::new(image))) + } + } + + fn get_image(&self, frame_index: usize) -> Arc { + match self { + Self::Static(image) => image.clone(), + Self::Animated(animation) => animation.get_image_by_index(frame_index), + } + } + + pub fn byte_len(&self) -> usize { + size_of::() + + match self { + Self::Static(image) => image.pixels.len() * size_of::(), + Self::Animated(animation) => animation.byte_len(), + } + } +} + +#[derive(Debug, Clone)] +pub struct AnimatedImage { + frames: Vec>, + frame_durations: FrameDurations, +} + +impl AnimatedImage { + pub fn byte_len(&self) -> usize { + size_of::() + + self + .frames + .iter() + .map(|image| { + image.pixels.len() * size_of::() + size_of::() + }) + .sum::() + } + + pub fn get_image_by_index(&self, index: usize) -> Arc { + self.frames[index % self.frames.len()].clone() + } +} + +type Entry = Result; + +#[derive(Default)] +pub struct WebPLoader { + cache: Mutex>, +} + +impl WebPLoader { + pub const ID: &'static str = egui::generate_loader_id!(WebPLoader); +} + +impl ImageLoader for WebPLoader { + fn id(&self) -> &str { + Self::ID + } + + fn load(&self, ctx: &egui::Context, frame_uri: &str, _: SizeHint) -> ImageLoadResult { + let (image_uri, frame_index) = + decode_animated_image_uri(frame_uri).map_err(|_error| LoadError::NotSupported)?; + + let mut cache = self.cache.lock(); + if let Some(entry) = cache.get(image_uri).cloned() { + match entry { + Ok(image) => Ok(ImagePoll::Ready { + image: image.get_image(frame_index), + }), + Err(error) => Err(LoadError::Loading(error)), + } + } else { + match ctx.try_load_bytes(image_uri) { + Ok(BytesPoll::Ready { bytes, .. }) => { + if !has_webp_header(&bytes) { + return Err(LoadError::NotSupported); + } + + log::trace!("started loading {image_uri:?}"); + + let result = WebP::load(&bytes); + + if let Ok(WebP::Animated(animated_image)) = &result { + ctx.data_mut(|data| { + *data.get_temp_mut_or_default(Id::new(image_uri)) = + animated_image.frame_durations.clone(); + }); + } + + log::trace!("finished loading {image_uri:?}"); + + cache.insert(image_uri.into(), result.clone()); + + match result { + Ok(image) => Ok(ImagePoll::Ready { + image: image.get_image(frame_index), + }), + Err(error) => Err(LoadError::Loading(error)), + } + } + Ok(BytesPoll::Pending { size }) => Ok(ImagePoll::Pending { size }), + Err(error) => Err(error), + } + } + } + + fn forget(&self, uri: &str) { + 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 { + Ok(entry_value) => entry_value.byte_len(), + Err(error) => error.len(), + }) + .sum() + } +} diff --git a/examples/images/screenshot.png b/examples/images/screenshot.png index 7d81312aafd..833b6565b2f 100644 --- a/examples/images/screenshot.png +++ b/examples/images/screenshot.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:12eb9463cda6c2b1a160f085324f1afdfc5ced9ff0857df117030d8771259e5e -size 303453 +oid sha256:a836741d52e1972b2047cefaabf59f601637d430d4b41bf6407ebda4f7931dac +size 273450 diff --git a/examples/images/src/cat.webp b/examples/images/src/cat.webp new file mode 100644 index 00000000000..a0c41da8968 Binary files /dev/null and b/examples/images/src/cat.webp differ diff --git a/examples/images/src/main.rs b/examples/images/src/main.rs index f2ce5729a22..a8373774a0e 100644 --- a/examples/images/src/main.rs +++ b/examples/images/src/main.rs @@ -6,7 +6,7 @@ use eframe::egui; fn main() -> eframe::Result { env_logger::init(); // Log to stderr (if you run with `RUST_LOG=debug`). let options = eframe::NativeOptions { - viewport: egui::ViewportBuilder::default().with_inner_size([400.0, 800.0]), + viewport: egui::ViewportBuilder::default().with_inner_size([320.0, 880.0]), ..Default::default() }; eframe::run_native( @@ -27,11 +27,16 @@ impl eframe::App for MyApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { egui::CentralPanel::default().show(ctx, |ui| { egui::ScrollArea::both().show(ui, |ui| { - ui.image(egui::include_image!("ferris.gif")); - ui.add( - egui::Image::new("https://picsum.photos/seed/1.759706314/1024").rounding(10.0), - ); - ui.image(egui::include_image!("ferris.svg")); + ui.image(egui::include_image!("cat.webp")) + .on_hover_text_at_pointer("WebP"); + ui.image(egui::include_image!("ferris.gif")) + .on_hover_text_at_pointer("Gif"); + ui.image(egui::include_image!("ferris.svg")) + .on_hover_text_at_pointer("Svg"); + + let url = "https://picsum.photos/seed/1.759706314/1024"; + ui.add(egui::Image::new(url).rounding(10.0)) + .on_hover_text_at_pointer(url); }); }); } diff --git a/scripts/setup_web.sh b/scripts/setup_web.sh index 879f0a77e68..d4166a3af2d 100755 --- a/scripts/setup_web.sh +++ b/scripts/setup_web.sh @@ -3,8 +3,12 @@ set -eu script_path=$( cd "$(dirname "${BASH_SOURCE[0]}")" ; pwd -P ) cd "$script_path/.." +set -x + # Pre-requisites: rustup target add wasm32-unknown-unknown # For generating JS bindings: -cargo install --quiet wasm-bindgen-cli --version 0.2.95 +if ! cargo install --list | grep -q 'wasm-bindgen-cli v0.2.95'; then + cargo install --force --quiet wasm-bindgen-cli --version 0.2.95 +fi