diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index e2a589698..b1f5a5a37 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -238,12 +238,10 @@ jobs: uses: Swatinem/rust-cache@v2 - name: Run tests - # TODO(lucasmerlin): Enable --all-features (currently this breaks the rendering in the tests because of the `unity` feature) - run: cargo test + run: cargo test --all-features - name: Run doc-tests - # TODO(lucasmerlin): Enable --all-features (currently this breaks the rendering in the tests because of the `unity` feature) - run: cargo test --doc + run: cargo test --all-features --doc - name: Upload artifacts uses: actions/upload-artifact@v4 diff --git a/.vscode/settings.json b/.vscode/settings.json index 681dc12fe..d4794e033 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -33,4 +33,8 @@ "--all-features", ], "rust-analyzer.showUnlinkedFileNotification": false, + + // Uncomment the following options and restart rust-analyzer to get it to check code behind `cfg(target_arch=wasm32)`. + // Don't forget to put it in a comment again before committing. + // "rust-analyzer.cargo.target": "wasm32-unknown-unknown", } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6777c9a15..cfeb0c247 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,7 +32,8 @@ For small things, just go ahead an open a PR. For bigger things, please file an Browse through [`ARCHITECTURE.md`](ARCHITECTURE.md) to get a sense of how all pieces connects. You can test your code locally by running `./scripts/check.sh`. -There are snapshots test that might need to be updated. Run the tests with `UPDATE_SNAPSHOTS=true` to update them. +There are snapshots test that might need to be updated. +Run the tests with `UPDATE_SNAPSHOTS=true cargo test --workspace --all-features` to update all of them. For more info about the tests see [egui_kittest](./crates/egui_kittest/README.md). We use [git-lfs](https://git-lfs.com/) to store big files in the repository. diff --git a/Cargo.lock b/Cargo.lock index 0a8777fc2..3fdd454dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -240,11 +240,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df099ccb16cd014ff054ac1bf392c67feeef57164b05c42f037cd40f5d4357f4" dependencies = [ "clipboard-win", + "core-graphics", + "image", "log", "objc2", "objc2-app-kit", "objc2-foundation", "parking_lot", + "windows-sys 0.48.0", "x11rb", ] @@ -1292,6 +1295,7 @@ dependencies = [ "accesskit_winit", "ahash", "arboard", + "bytemuck", "document-features", "egui", "log", @@ -2203,12 +2207,24 @@ dependencies = [ "byteorder-lite", "color_quant", "gif", + "image-webp", "num-traits", "png", + "tiff", "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" @@ -2301,6 +2317,12 @@ dependencies = [ "libc", ] +[[package]] +name = "jpeg-decoder" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5d4a7da358eff58addd2877a45865158f0d78c911d43a5784ceb7bbf52833b0" + [[package]] name = "js-sys" version = "0.3.72" @@ -3166,6 +3188,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" @@ -3866,6 +3894,17 @@ dependencies = [ "syn", ] +[[package]] +name = "tiff" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e" +dependencies = [ + "flate2", + "jpeg-decoder", + "weezl", +] + [[package]] name = "time" version = "0.3.36" diff --git a/crates/eframe/Cargo.toml b/crates/eframe/Cargo.toml index eb4766dfa..f2384b878 100644 --- a/crates/eframe/Cargo.toml +++ b/crates/eframe/Cargo.toml @@ -203,6 +203,7 @@ windows-sys = { workspace = true, features = [ # web: [target.'cfg(target_arch = "wasm32")'.dependencies] bytemuck.workspace = true +image = { workspace = true, features = ["png"] } # For copying images js-sys = "0.3" percent-encoding = "2.1" wasm-bindgen.workspace = true @@ -210,8 +211,10 @@ wasm-bindgen-futures.workspace = true web-sys = { workspace = true, features = [ "BinaryType", "Blob", + "BlobPropertyBag", "Clipboard", "ClipboardEvent", + "ClipboardItem", "CompositionEvent", "console", "CssStyleDeclaration", diff --git a/crates/eframe/src/web/app_runner.rs b/crates/eframe/src/web/app_runner.rs index 13ad76287..6d11069f8 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,22 @@ 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::CopyImage(image) => { + super::set_clipboard_image(&image); + } + 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/eframe/src/web/mod.rs b/crates/eframe/src/web/mod.rs index 827feb924..911c453f2 100644 --- a/crates/eframe/src/web/mod.rs +++ b/crates/eframe/src/web/mod.rs @@ -192,6 +192,95 @@ fn set_clipboard_text(s: &str) { } } +/// Set the clipboard image. +fn set_clipboard_image(image: &egui::ColorImage) { + if let Some(window) = web_sys::window() { + if !window.is_secure_context() { + log::error!( + "Clipboard is not available because we are not in a secure context. \ + See https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts" + ); + return; + } + + let png_bytes = to_image(image).and_then(|image| to_png_bytes(&image)); + let png_bytes = match png_bytes { + Ok(png_bytes) => png_bytes, + Err(err) => { + log::error!("Failed to encode image to png: {err}"); + return; + } + }; + + let mime = "image/png"; + + let item = match create_clipboard_item(mime, &png_bytes) { + Ok(item) => item, + Err(err) => { + log::error!("Failed to copy image: {}", string_from_js_value(&err)); + return; + } + }; + let items = js_sys::Array::of1(&item); + let promise = window.navigator().clipboard().write(&items); + let future = wasm_bindgen_futures::JsFuture::from(promise); + let future = async move { + if let Err(err) = future.await { + log::error!( + "Copy/cut image action failed: {}", + string_from_js_value(&err) + ); + } + }; + wasm_bindgen_futures::spawn_local(future); + } +} + +fn to_image(image: &egui::ColorImage) -> Result { + profiling::function_scope!(); + image::RgbaImage::from_raw( + image.width() as _, + image.height() as _, + bytemuck::cast_slice(&image.pixels).to_vec(), + ) + .ok_or_else(|| "Invalid IconData".to_owned()) +} + +fn to_png_bytes(image: &image::RgbaImage) -> Result, String> { + profiling::function_scope!(); + let mut png_bytes: Vec = Vec::new(); + image + .write_to( + &mut std::io::Cursor::new(&mut png_bytes), + image::ImageFormat::Png, + ) + .map_err(|err| err.to_string())?; + Ok(png_bytes) +} + +fn create_clipboard_item(mime: &str, bytes: &[u8]) -> Result { + let array = js_sys::Uint8Array::from(bytes); + let blob_parts = js_sys::Array::new(); + blob_parts.push(&array); + + let options = web_sys::BlobPropertyBag::new(); + options.set_type(mime); + + let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&blob_parts, &options)?; + + let items = js_sys::Object::new(); + + // SAFETY: I hope so + #[allow(unsafe_code, unused_unsafe)] // Weird false positive + unsafe { + js_sys::Reflect::set(&items, &JsValue::from_str(mime), &blob)? + }; + + let clipboard_item = web_sys::ClipboardItem::new_with_record_from_str_to_blob_promise(&items)?; + + Ok(clipboard_item) +} + fn cursor_web_name(cursor: egui::CursorIcon) -> &'static str { match cursor { egui::CursorIcon::Alias => "alias", diff --git a/crates/egui-winit/Cargo.toml b/crates/egui-winit/Cargo.toml index c584db85e..e4a837823 100644 --- a/crates/egui-winit/Cargo.toml +++ b/crates/egui-winit/Cargo.toml @@ -36,11 +36,11 @@ android-game-activity = ["winit/android-game-activity"] android-native-activity = ["winit/android-native-activity"] ## [`bytemuck`](https://docs.rs/bytemuck) enables you to cast [`egui::epaint::Vertex`], [`egui::Vec2`] etc to `&[u8]`. -bytemuck = ["egui/bytemuck"] +bytemuck = ["egui/bytemuck", "dep:bytemuck"] ## Enable cut/copy/paste to OS clipboard. ## If disabled a clipboard will be simulated so you can still copy/paste within the egui app. -clipboard = ["arboard", "smithay-clipboard"] +clipboard = ["arboard", "bytemuck", "smithay-clipboard"] ## Enable opening links in a browser when an egui hyperlink is clicked. links = ["webbrowser"] @@ -69,6 +69,8 @@ winit = { workspace = true, default-features = false } # feature accesskit accesskit_winit = { version = "0.23", optional = true } +bytemuck = { workspace = true, optional = true } + ## Enable this when generating docs. document-features = { workspace = true, optional = true } @@ -84,4 +86,6 @@ smithay-clipboard = { version = "0.7.2", optional = true } wayland-cursor = { version = "0.31.1", default-features = false, optional = true } [target.'cfg(not(target_os = "android"))'.dependencies] -arboard = { version = "3.3", optional = true, default-features = false } +arboard = { version = "3.3", optional = true, default-features = false, features = [ + "image-data", +] } diff --git a/crates/egui-winit/src/clipboard.rs b/crates/egui-winit/src/clipboard.rs index c4192f78d..c8adcad21 100644 --- a/crates/egui-winit/src/clipboard.rs +++ b/crates/egui-winit/src/clipboard.rs @@ -82,7 +82,7 @@ impl Clipboard { Some(self.clipboard.clone()) } - pub fn set(&mut self, text: String) { + pub fn set_text(&mut self, text: String) { #[cfg(all( any( target_os = "linux", @@ -108,6 +108,24 @@ impl Clipboard { self.clipboard = text; } + + pub fn set_image(&mut self, image: &egui::ColorImage) { + #[cfg(all(feature = "arboard", not(target_os = "android")))] + if let Some(clipboard) = &mut self.arboard { + if let Err(err) = clipboard.set_image(arboard::ImageData { + width: image.width(), + height: image.height(), + bytes: std::borrow::Cow::Borrowed(bytemuck::cast_slice(&image.pixels)), + }) { + log::error!("arboard copy/cut error: {err}"); + } + log::debug!("Copied image to clipboard"); + return; + } + + log::error!("Copying images is not supported. Enable the 'clipboard' feature of `egui-winit` to enable it."); + _ = image; + } } #[cfg(all(feature = "arboard", not(target_os = "android")))] diff --git a/crates/egui-winit/src/lib.rs b/crates/egui-winit/src/lib.rs index 50ff2d31b..f3612f501 100644 --- a/crates/egui-winit/src/lib.rs +++ b/crates/egui-winit/src/lib.rs @@ -190,7 +190,7 @@ impl State { /// Places the text onto the clipboard. pub fn set_clipboard_text(&mut self, text: String) { - self.clipboard.set(text); + self.clipboard.set_text(text); } /// Returns [`false`] or the last value that [`Window::set_ime_allowed()`] was called with, used for debouncing. @@ -333,43 +333,45 @@ impl State { } WindowEvent::Ime(ime) => { - if cfg!(target_os = "linux") { - // We ignore IME events on linux, because of https://github.com/emilk/egui/issues/5008 - } else { - // on Mac even Cmd-C is pressed during ime, a `c` is pushed to Preedit. - // So no need to check is_mac_cmd. - // - // How winit produce `Ime::Enabled` and `Ime::Disabled` differs in MacOS - // and Windows. - // - // - On Windows, before and after each Commit will produce an Enable/Disabled - // event. - // - On MacOS, only when user explicit enable/disable ime. No Disabled - // after Commit. - // - // We use input_method_editor_started to manually insert CompositionStart - // between Commits. - match ime { - winit::event::Ime::Enabled => { + // on Mac even Cmd-C is pressed during ime, a `c` is pushed to Preedit. + // So no need to check is_mac_cmd. + // + // How winit produce `Ime::Enabled` and `Ime::Disabled` differs in MacOS + // and Windows. + // + // - On Windows, before and after each Commit will produce an Enable/Disabled + // event. + // - On MacOS, only when user explicit enable/disable ime. No Disabled + // after Commit. + // + // We use input_method_editor_started to manually insert CompositionStart + // between Commits. + match ime { + winit::event::Ime::Enabled => { + if cfg!(target_os = "linux") { + // This event means different things in X11 and Wayland, but we can just + // ignore it and enable IME on the preedit event. + // See + } else { self.ime_event_enable(); } - winit::event::Ime::Preedit(text, Some(_cursor)) => { - self.ime_event_enable(); - self.egui_input - .events - .push(egui::Event::Ime(egui::ImeEvent::Preedit(text.clone()))); - } - winit::event::Ime::Commit(text) => { - self.egui_input - .events - .push(egui::Event::Ime(egui::ImeEvent::Commit(text.clone()))); - self.ime_event_disable(); - } - winit::event::Ime::Disabled | winit::event::Ime::Preedit(_, None) => { - self.ime_event_disable(); - } - }; - } + } + winit::event::Ime::Preedit(text, Some(_cursor)) => { + self.ime_event_enable(); + self.egui_input + .events + .push(egui::Event::Ime(egui::ImeEvent::Preedit(text.clone()))); + } + winit::event::Ime::Commit(text) => { + self.egui_input + .events + .push(egui::Event::Ime(egui::ImeEvent::Commit(text.clone()))); + self.ime_event_disable(); + } + winit::event::Ime::Disabled | winit::event::Ime::Preedit(_, None) => { + self.ime_event_disable(); + } + }; EventResponse { repaint: true, @@ -820,9 +822,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 +839,20 @@ 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(text); + } + egui::OutputCommand::CopyImage(image) => { + self.clipboard.set_image(&image); + } + 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 { @@ -842,7 +860,7 @@ impl State { } if !copied_text.is_empty() { - self.clipboard.set(copied_text); + self.clipboard.set_text(copied_text); } let allow_ime = ime.is_some(); diff --git a/crates/egui/Cargo.toml b/crates/egui/Cargo.toml index ce7999dd1..a0f11fdfa 100644 --- a/crates/egui/Cargo.toml +++ b/crates/egui/Cargo.toml @@ -74,6 +74,10 @@ serde = ["dep:serde", "epaint/serde", "accesskit?/serde"] ## Change Vertex layout to be compatible with unity unity = ["epaint/unity"] +## Override and disable the unity feature +## This exists, so that when testing with --all-features, snapshots render correctly. +_override_unity = ["epaint/_override_unity"] + [dependencies] emath = { workspace = true, default-features = false } diff --git a/crates/egui/src/containers/frame.rs b/crates/egui/src/containers/frame.rs index 432356a87..cf1f5a3ac 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/context.rs b/crates/egui/src/context.rs index 118cddb31..3ecf42ca4 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,25 @@ 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., + /// Note that in web 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)); + } + + /// Copy the given image to the system clipboard. + /// + /// Note that in web 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 . + pub fn copy_image(&self, image: crate::ColorImage) { + self.send_cmd(crate::OutputCommand::CopyImage(image)); } /// 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 a878bd5fd..64a30d361 100644 --- a/crates/egui/src/data/output.rs +++ b/crates/egui/src/data/output.rs @@ -79,6 +79,24 @@ 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 to the system clipboard. + /// + /// This is often a response to [`crate::Event::Copy`] or [`crate::Event::Cut`]. + CopyText(String), + + /// Put this image to the system clipboard. + CopyImage(crate::ColorImage), + + /// 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 +105,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 +126,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 +185,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 +201,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 +240,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, @@ -673,6 +700,7 @@ impl WidgetInfo { WidgetType::DragValue => "drag value", WidgetType::ColorButton => "color button", WidgetType::ImageButton => "image button", + WidgetType::Image => "image", WidgetType::CollapsingHeader => "collapsing header", WidgetType::ProgressIndicator => "progress indicator", WidgetType::Window => "window", diff --git a/crates/egui/src/lib.rs b/crates/egui/src/lib.rs index 1afaada95..fa3cb8585 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, }, @@ -665,6 +666,8 @@ pub enum WidgetType { ImageButton, + Image, + CollapsingHeader, ProgressIndicator, diff --git a/crates/egui/src/response.rs b/crates/egui/src/response.rs index 18ddf793c..c65d9ca8c 100644 --- a/crates/egui/src/response.rs +++ b/crates/egui/src/response.rs @@ -1017,6 +1017,7 @@ impl Response { WidgetType::Button | WidgetType::ImageButton | WidgetType::CollapsingHeader => { Role::Button } + WidgetType::Image => Role::Image, WidgetType::Checkbox => Role::CheckBox, WidgetType::RadioButton => Role::RadioButton, WidgetType::RadioGroup => Role::RadioGroup, diff --git a/crates/egui/src/viewport.rs b/crates/egui/src/viewport.rs index 91cd12e2b..d3f1c389d 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/button.rs b/crates/egui/src/widgets/button.rs index e4355b49e..088800e45 100644 --- a/crates/egui/src/widgets/button.rs +++ b/crates/egui/src/widgets/button.rs @@ -344,6 +344,7 @@ impl Widget for Button<'_> { image_rect, image.show_loading_spinner, &image_options, + None, ); response = widgets::image::texture_load_result_response( &image.source(ui.ctx()), diff --git a/crates/egui/src/widgets/image.rs b/crates/egui/src/widgets/image.rs index 4cdfc5bf7..3abcf5fd5 100644 --- a/crates/egui/src/widgets/image.rs +++ b/crates/egui/src/widgets/image.rs @@ -1,12 +1,15 @@ -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; +use emath::{Align, Float as _, Rot2}; +use epaint::{ + text::{LayoutJob, TextFormat, TextWrapping}, + RectShape, +}; use crate::{ load::{Bytes, SizeHint, SizedTexture, TextureLoadResult, TexturePoll}, - pos2, Align2, Color32, Context, Id, Mesh, Painter, Rect, Response, Rounding, Sense, Shape, - Spinner, Stroke, TextStyle, TextureOptions, Ui, Vec2, Widget, + pos2, Color32, Context, Id, Mesh, Painter, Rect, Response, Rounding, Sense, Shape, Spinner, + Stroke, TextStyle, TextureOptions, Ui, Vec2, Widget, WidgetInfo, WidgetType, }; /// A widget which displays an image. @@ -51,6 +54,7 @@ pub struct Image<'a> { sense: Sense, size: ImageSize, pub(crate) show_loading_spinner: Option, + alt_text: Option, } impl<'a> Image<'a> { @@ -76,6 +80,7 @@ impl<'a> Image<'a> { sense: Sense::hover(), size, show_loading_spinner: None, + alt_text: None, } } @@ -255,6 +260,14 @@ impl<'a> Image<'a> { self.show_loading_spinner = Some(show); self } + + /// Set alt text for the image. This will be shown when the image fails to load. + /// It will also be read to screen readers. + #[inline] + pub fn alt_text(mut self, label: impl Into) -> Self { + self.alt_text = Some(label.into()); + self + } } impl<'a, T: Into>> From for Image<'a> { @@ -286,12 +299,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 +319,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)) } @@ -352,6 +367,7 @@ impl<'a> Image<'a> { rect, self.show_loading_spinner, &self.image_options, + self.alt_text.as_deref(), ); } } @@ -363,6 +379,11 @@ impl<'a> Widget for Image<'a> { let ui_size = self.calc_size(ui.available_size(), original_image_size); let (rect, response) = ui.allocate_exact_size(ui_size, self.sense); + response.widget_info(|| { + let mut info = WidgetInfo::new(WidgetType::Image); + info.label = self.alt_text.clone(); + info + }); if ui.is_rect_visible(rect) { paint_texture_load_result( ui, @@ -370,6 +391,7 @@ impl<'a> Widget for Image<'a> { rect, self.show_loading_spinner, &self.image_options, + self.alt_text.as_deref(), ); } texture_load_result_response(&self.source(ui.ctx()), &tlr, response) @@ -600,6 +622,7 @@ pub fn paint_texture_load_result( rect: Rect, show_loading_spinner: Option, options: &ImageOptions, + alt: Option<&str>, ) { match tlr { Ok(TexturePoll::Ready { texture }) => { @@ -614,12 +637,28 @@ pub fn paint_texture_load_result( } Err(_) => { let font_id = TextStyle::Body.resolve(ui.style()); - ui.painter().text( - rect.center(), - Align2::CENTER_CENTER, + let mut job = LayoutJob { + wrap: TextWrapping::truncate_at_width(rect.width()), + halign: Align::Center, + ..Default::default() + }; + job.append( "⚠", - font_id, - ui.visuals().error_fg_color, + 0.0, + TextFormat::simple(font_id.clone(), ui.visuals().error_fg_color), + ); + if let Some(alt) = alt { + job.append( + alt, + ui.spacing().item_spacing.x, + TextFormat::simple(font_id, ui.visuals().text_color()), + ); + } + let galley = ui.painter().layout_job(job); + ui.painter().galley( + rect.center() - Vec2::Y * galley.size().y * 0.5, + galley, + ui.visuals().text_color(), ); } } @@ -796,57 +835,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#") -} +/// 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)); -/// checks if bytes are gifs -pub fn has_gif_magic_header(bytes: &[u8]) -> bool { - bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") -} + let durations: Option = ctx.data(|data| data.get_temp(Id::new(uri))); -/// 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))); 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/image_button.rs b/crates/egui/src/widgets/image_button.rs index bcae9a991..fdcae898a 100644 --- a/crates/egui/src/widgets/image_button.rs +++ b/crates/egui/src/widgets/image_button.rs @@ -11,6 +11,7 @@ pub struct ImageButton<'a> { sense: Sense, frame: bool, selected: bool, + alt_text: Option, } impl<'a> ImageButton<'a> { @@ -20,6 +21,7 @@ impl<'a> ImageButton<'a> { sense: Sense::click(), frame: true, selected: false, + alt_text: None, } } @@ -87,7 +89,11 @@ impl<'a> Widget for ImageButton<'a> { let padded_size = image_size + 2.0 * padding; let (rect, response) = ui.allocate_exact_size(padded_size, self.sense); - response.widget_info(|| WidgetInfo::new(WidgetType::ImageButton)); + response.widget_info(|| { + let mut info = WidgetInfo::new(WidgetType::ImageButton); + info.label = self.alt_text.clone(); + info + }); if ui.is_rect_visible(rect) { let (expansion, rounding, fill, stroke) = if self.selected { @@ -121,7 +127,14 @@ impl<'a> Widget for ImageButton<'a> { // let image_rect = image_rect.expand2(expansion); // can make it blurry, so let's not let image_options = self.image.image_options().clone(); - widgets::image::paint_texture_load_result(ui, &tlr, image_rect, None, &image_options); + widgets::image::paint_texture_load_result( + ui, + &tlr, + image_rect, + None, + &image_options, + self.alt_text.as_deref(), + ); // Draw frame outline: ui.painter() diff --git a/crates/egui/src/widgets/mod.rs b/crates/egui/src/widgets/mod.rs index 78e095aef..a4a40ec66 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_app/src/apps/image_viewer.rs b/crates/egui_demo_app/src/apps/image_viewer.rs index ad7a84486..80961915e 100644 --- a/crates/egui_demo_app/src/apps/image_viewer.rs +++ b/crates/egui_demo_app/src/apps/image_viewer.rs @@ -14,6 +14,7 @@ pub struct ImageViewer { fit: ImageFit, maintain_aspect_ratio: bool, max_size: Vec2, + alt_text: String, } #[derive(Clone, Copy, PartialEq, Eq)] @@ -44,6 +45,7 @@ impl Default for ImageViewer { fit: ImageFit::Fraction(Vec2::splat(1.0)), maintain_aspect_ratio: true, max_size: Vec2::splat(2048.0), + alt_text: "My Image".to_owned(), } } } @@ -185,6 +187,11 @@ impl eframe::App for ImageViewer { ui.label("Aspect ratio is maintained by scaling both sides as necessary"); ui.checkbox(&mut self.maintain_aspect_ratio, "Maintain aspect ratio"); + // alt text + ui.add_space(5.0); + ui.label("Alt text"); + ui.text_edit_singleline(&mut self.alt_text); + // forget all images if ui.button("Forget all images").clicked() { ui.ctx().forget_all_images(); @@ -211,6 +218,9 @@ impl eframe::App for ImageViewer { } image = image.maintain_aspect_ratio(self.maintain_aspect_ratio); image = image.max_size(self.max_size); + if !self.alt_text.is_empty() { + image = image.alt_text(&self.alt_text); + } ui.add_sized(ui.available_size(), image); }); diff --git a/crates/egui_demo_lib/src/demo/about.rs b/crates/egui_demo_lib/src/demo/about.rs index 4a3bfd456..22fa70635 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 3db90ad3e..9094c19a7 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 2cfcdfaee..7e4891e1f 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 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 } +impl DemoGroup { + pub fn new(demos: Vec>) -> Self { + Self { demos } } - 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,65 +39,6 @@ impl Demos { } } -// ---------------------------------------------------------------------------- - -#[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, -} - -impl Default for Tests { - fn default() -> Self { - Self::from_demos(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); - } - } - - 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); - } - } -} - -// ---------------------------------------------------------------------------- - fn set_open(open: &mut BTreeSet, key: &'static str, is_open: bool) { if is_open { if !open.contains(key) { @@ -166,23 +51,132 @@ fn set_open(open: &mut BTreeSet, key: &'static str, is_open: bool) { // ---------------------------------------------------------------------------- +pub struct DemoGroups { + about: About, + demos: DemoGroup, + tests: DemoGroup, +} + +impl Default for DemoGroups { + fn default() -> Self { + 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(), + Box::::default(), + ]), + } + } +} + +impl DemoGroups { + pub fn checkboxes(&mut self, ui: &mut Ui, open: &mut BTreeSet) { + let Self { + about, + demos, + tests, + } = self; + + { + 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); + } + + 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); + } + demos.windows(ctx, open); + tests.windows(ctx, open); + } +} + +// ---------------------------------------------------------------------------- + /// A menu bar in which you can select different demo windows to show. #[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 +191,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 +285,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()); - + self.groups.checkboxes(ui, &mut self.open); ui.separator(); - self.demos.checkboxes(ui); - ui.separator(); - self.tests.checkboxes(ui); - ui.separator(); - if ui.button("Organize windows").clicked() { ui.ctx().memory_mut(|mem| mem.reset_areas()); } @@ -382,29 +362,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 e2310f8a1..352bc0273 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/tests/clipboard_test.rs b/crates/egui_demo_lib/src/demo/tests/clipboard_test.rs new file mode 100644 index 000000000..e602d046c --- /dev/null +++ b/crates/egui_demo_lib/src/demo/tests/clipboard_test.rs @@ -0,0 +1,81 @@ +pub struct ClipboardTest { + text: String, +} + +impl Default for ClipboardTest { + fn default() -> Self { + Self { + text: "Example text you can copy-and-paste".to_owned(), + } + } +} + +impl crate::Demo for ClipboardTest { + fn name(&self) -> &'static str { + "Clipboard Test" + } + + fn show(&mut self, ctx: &egui::Context, open: &mut bool) { + egui::Window::new(self.name()).open(open).show(ctx, |ui| { + use crate::View as _; + self.ui(ui); + }); + } +} + +impl crate::View for ClipboardTest { + fn ui(&mut self, ui: &mut egui::Ui) { + ui.label("egui integrates with the system clipboard."); + ui.label("Try copy-cut-pasting text in the text edit below."); + + let text_edit_response = ui + .horizontal(|ui| { + let text_edit_response = ui.text_edit_singleline(&mut self.text); + if ui.button("📋").clicked() { + ui.ctx().copy_text(self.text.clone()); + } + text_edit_response + }) + .inner; + + if !cfg!(target_arch = "wasm32") { + // These commands are not yet implemented on web + ui.horizontal(|ui| { + for (name, cmd) in [ + ("Copy", egui::ViewportCommand::RequestCopy), + ("Cut", egui::ViewportCommand::RequestCut), + ("Paste", egui::ViewportCommand::RequestPaste), + ] { + if ui.button(name).clicked() { + // Next frame we should get a copy/cut/paste-event… + ui.ctx().send_viewport_cmd(cmd); + + // …that should en up here: + text_edit_response.request_focus(); + } + } + }); + } + + ui.separator(); + + ui.label("You can also copy images:"); + ui.horizontal(|ui| { + let image_source = egui::include_image!("../../../data/icon.png"); + let uri = image_source.uri().unwrap().to_owned(); + ui.image(image_source); + + if let Ok(egui::load::ImagePoll::Ready { image }) = + ui.ctx().try_load_image(&uri, Default::default()) + { + if ui.button("📋").clicked() { + ui.ctx().copy_image((*image).clone()); + } + } + }); + + ui.vertical_centered_justified(|ui| { + ui.add(crate::egui_github_link_file!()); + }); + } +} diff --git a/crates/egui_demo_lib/src/demo/tests/mod.rs b/crates/egui_demo_lib/src/demo/tests/mod.rs index 78ad0a011..13332fea7 100644 --- a/crates/egui_demo_lib/src/demo/tests/mod.rs +++ b/crates/egui_demo_lib/src/demo/tests/mod.rs @@ -1,3 +1,4 @@ +mod clipboard_test; mod cursor_test; mod grid_test; mod id_test; @@ -7,6 +8,7 @@ mod layout_test; mod manual_layout_test; mod window_resize_test; +pub use clipboard_test::ClipboardTest; pub use cursor_test::CursorTest; pub use grid_test::GridTest; pub use id_test::IdTest; diff --git a/crates/egui_demo_lib/src/demo/widget_gallery.rs b/crates/egui_demo_lib/src/demo/widget_gallery.rs index b69d0f1c8..d473d4b9d 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/src/rendering_test.rs b/crates/egui_demo_lib/src/rendering_test.rs index bd454267e..011ca4736 100644 --- a/crates/egui_demo_lib/src/rendering_test.rs +++ b/crates/egui_demo_lib/src/rendering_test.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use egui::{ - emath::GuiRounding, epaint, lerp, pos2, vec2, widgets::color_picker::show_color, Align2, + emath::GuiRounding as _, epaint, lerp, pos2, vec2, widgets::color_picker::show_color, Align2, Color32, FontId, Image, Mesh, Pos2, Rect, Response, Rgba, RichText, Sense, Shape, Stroke, TextureHandle, TextureOptions, Ui, Vec2, }; diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Code Editor.png b/crates/egui_demo_lib/tests/snapshots/demos/Code Editor.png index 77ebb0094..703c9ef19 100644 --- a/crates/egui_demo_lib/tests/snapshots/demos/Code Editor.png +++ b/crates/egui_demo_lib/tests/snapshots/demos/Code Editor.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b8ca5a27491c0589a97e43a70bc10dc52778d25ca3f7e7c895dbbbb784adfcfa -size 33245 +oid sha256:0a1099b85a1aaf20f3f1e091bc68259f811737feaefdfcc12acd067eca8f9117 +size 27083 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 04fa9ba3c..0f417f0b4 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:6969c6da67ea6cc7ebbbd7a2cc1cb13d4720befe28126367cbf2b2679d037674 +size 82363 diff --git a/crates/egui_demo_lib/tests/snapshots/widget_gallery.png b/crates/egui_demo_lib/tests/snapshots/widget_gallery.png index 515962474..6f06e7727 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 41fbcf0a4..89465f6d1 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 02683e442..03b1abfc9 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 1c2013515..a92cbc33e 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 4c1a846e2..171e56170 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 000000000..bb042093b --- /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/crates/egui_kittest/tests/regression_tests.rs b/crates/egui_kittest/tests/regression_tests.rs index 9493d5443..690ca86f6 100644 --- a/crates/egui_kittest/tests/regression_tests.rs +++ b/crates/egui_kittest/tests/regression_tests.rs @@ -1,4 +1,4 @@ -use egui::Button; +use egui::{Button, Image, Vec2, Widget}; use egui_kittest::{kittest::Queryable, Harness}; #[test] @@ -27,3 +27,19 @@ pub fn focus_should_skip_over_disabled_buttons() { let button_1 = harness.get_by_label("Button 1"); assert!(button_1.is_focused()); } + +#[test] +fn image_failed() { + let mut harness = Harness::new_ui(|ui| { + Image::new("file://invalid/path") + .alt_text("I have an alt text") + .max_size(Vec2::new(100.0, 100.0)) + .ui(ui); + }); + + harness.run(); + harness.fit_contents(); + + #[cfg(all(feature = "wgpu", feature = "snapshot"))] + harness.wgpu_snapshot("image_snapshots"); +} diff --git a/crates/egui_kittest/tests/snapshots/image_snapshots.png b/crates/egui_kittest/tests/snapshots/image_snapshots.png new file mode 100644 index 000000000..c1b7d6cef --- /dev/null +++ b/crates/egui_kittest/tests/snapshots/image_snapshots.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:31faeb4e5f488b8bcee5e090accd326d7e43b264e81768ae7c1907e3b6d0f739 +size 2121 diff --git a/crates/epaint/Cargo.toml b/crates/epaint/Cargo.toml index 0a4afde26..720188872 100644 --- a/crates/epaint/Cargo.toml +++ b/crates/epaint/Cargo.toml @@ -66,6 +66,10 @@ serde = ["dep:serde", "ahash/serde", "emath/serde", "ecolor/serde"] ## Change Vertex layout to be compatible with unity unity = [] +## Override and disable the unity feature +## This exists, so that when testing with --all-features, snapshots render correctly. +_override_unity = [] + [dependencies] emath.workspace = true ecolor.workspace = true diff --git a/crates/epaint/src/image.rs b/crates/epaint/src/image.rs index 9a204a121..f653be2e7 100644 --- a/crates/epaint/src/image.rs +++ b/crates/epaint/src/image.rs @@ -143,37 +143,6 @@ impl ColorImage { bytemuck::cast_slice_mut(&mut self.pixels) } - /// Create a new Image from a patch of the current image. This method is especially convenient for screenshotting a part of the app - /// since `region` can be interpreted as screen coordinates of the entire screenshot if `pixels_per_point` is provided for the native application. - /// The floats of [`emath::Rect`] are cast to usize, rounding them down in order to interpret them as indices to the image data. - /// - /// Panics if `region.min.x > region.max.x || region.min.y > region.max.y`, or if a region larger than the image is passed. - pub fn region(&self, region: &emath::Rect, pixels_per_point: Option) -> Self { - let pixels_per_point = pixels_per_point.unwrap_or(1.0); - let min_x = (region.min.x * pixels_per_point) as usize; - let max_x = (region.max.x * pixels_per_point) as usize; - let min_y = (region.min.y * pixels_per_point) as usize; - let max_y = (region.max.y * pixels_per_point) as usize; - assert!( - min_x <= max_x && min_y <= max_y, - "Screenshot region is invalid: {region:?}" - ); - let width = max_x - min_x; - let height = max_y - min_y; - let mut output = Vec::with_capacity(width * height); - let row_stride = self.size[0]; - - for row in min_y..max_y { - output.extend_from_slice( - &self.pixels[row * row_stride + min_x..row * row_stride + max_x], - ); - } - Self { - size: [width, height], - pixels: output, - } - } - /// Create a [`ColorImage`] from flat RGB data. /// /// This is what you want to use after having loaded an image file (and if @@ -215,6 +184,39 @@ impl ColorImage { pub fn height(&self) -> usize { self.size[1] } + + /// Create a new image from a patch of the current image. + /// + /// This method is especially convenient for screenshotting a part of the app + /// since `region` can be interpreted as screen coordinates of the entire screenshot if `pixels_per_point` is provided for the native application. + /// The floats of [`emath::Rect`] are cast to usize, rounding them down in order to interpret them as indices to the image data. + /// + /// Panics if `region.min.x > region.max.x || region.min.y > region.max.y`, or if a region larger than the image is passed. + pub fn region(&self, region: &emath::Rect, pixels_per_point: Option) -> Self { + let pixels_per_point = pixels_per_point.unwrap_or(1.0); + let min_x = (region.min.x * pixels_per_point) as usize; + let max_x = (region.max.x * pixels_per_point) as usize; + let min_y = (region.min.y * pixels_per_point) as usize; + let max_y = (region.max.y * pixels_per_point) as usize; + assert!( + min_x <= max_x && min_y <= max_y, + "Screenshot region is invalid: {region:?}" + ); + let width = max_x - min_x; + let height = max_y - min_y; + let mut output = Vec::with_capacity(width * height); + let row_stride = self.size[0]; + + for row in min_y..max_y { + output.extend_from_slice( + &self.pixels[row * row_stride + min_x..row * row_stride + max_x], + ); + } + Self { + size: [width, height], + pixels: output, + } + } } impl std::ops::Index<(usize, usize)> for ColorImage { diff --git a/crates/epaint/src/mesh.rs b/crates/epaint/src/mesh.rs index 2447cad29..495759d04 100644 --- a/crates/epaint/src/mesh.rs +++ b/crates/epaint/src/mesh.rs @@ -6,7 +6,7 @@ use emath::{Pos2, Rect, Rot2, TSTransform, Vec2}; /// Should be friendly to send to GPU as is. #[repr(C)] #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -#[cfg(not(feature = "unity"))] +#[cfg(any(not(feature = "unity"), feature = "_override_unity"))] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[cfg_attr(feature = "bytemuck", derive(bytemuck::Pod, bytemuck::Zeroable))] pub struct Vertex { @@ -25,7 +25,7 @@ pub struct Vertex { #[repr(C)] #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -#[cfg(feature = "unity")] +#[cfg(all(feature = "unity", not(feature = "_override_unity")))] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[cfg_attr(feature = "bytemuck", derive(bytemuck::Pod, bytemuck::Zeroable))] pub struct Vertex { diff --git a/examples/images/screenshot.png b/examples/images/screenshot.png index 7d81312aa..833b6565b 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 000000000..a0c41da89 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 f2ce5729a..a8373774a 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 879f0a77e..d4166a3af 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