1
0
mirror of https://github.com/emilk/egui.git synced 2026-08-31 13:50:04 -04:00

Merge branch 'master' into cache_galley_lines

This commit is contained in:
Emil Ernerfeldt
2025-01-02 02:09:31 +01:00
47 changed files with 1026 additions and 366 deletions

View File

@@ -238,12 +238,10 @@ jobs:
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2
- name: Run tests - name: Run tests
# TODO(lucasmerlin): Enable --all-features (currently this breaks the rendering in the tests because of the `unity` feature) run: cargo test --all-features
run: cargo test
- name: Run doc-tests - 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 --all-features --doc
run: cargo test --doc
- name: Upload artifacts - name: Upload artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4

View File

@@ -33,4 +33,8 @@
"--all-features", "--all-features",
], ],
"rust-analyzer.showUnlinkedFileNotification": false, "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",
} }

View File

@@ -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. 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`. 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). 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. We use [git-lfs](https://git-lfs.com/) to store big files in the repository.

View File

@@ -240,11 +240,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df099ccb16cd014ff054ac1bf392c67feeef57164b05c42f037cd40f5d4357f4" checksum = "df099ccb16cd014ff054ac1bf392c67feeef57164b05c42f037cd40f5d4357f4"
dependencies = [ dependencies = [
"clipboard-win", "clipboard-win",
"core-graphics",
"image",
"log", "log",
"objc2", "objc2",
"objc2-app-kit", "objc2-app-kit",
"objc2-foundation", "objc2-foundation",
"parking_lot", "parking_lot",
"windows-sys 0.48.0",
"x11rb", "x11rb",
] ]
@@ -1292,6 +1295,7 @@ dependencies = [
"accesskit_winit", "accesskit_winit",
"ahash", "ahash",
"arboard", "arboard",
"bytemuck",
"document-features", "document-features",
"egui", "egui",
"log", "log",
@@ -2203,12 +2207,24 @@ dependencies = [
"byteorder-lite", "byteorder-lite",
"color_quant", "color_quant",
"gif", "gif",
"image-webp",
"num-traits", "num-traits",
"png", "png",
"tiff",
"zune-core", "zune-core",
"zune-jpeg", "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]] [[package]]
name = "images" name = "images"
version = "0.1.0" version = "0.1.0"
@@ -2301,6 +2317,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "jpeg-decoder"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5d4a7da358eff58addd2877a45865158f0d78c911d43a5784ceb7bbf52833b0"
[[package]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.72" version = "0.3.72"
@@ -3166,6 +3188,12 @@ dependencies = [
"puffin_http", "puffin_http",
] ]
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.30.0" version = "0.30.0"
@@ -3866,6 +3894,17 @@ dependencies = [
"syn", "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]] [[package]]
name = "time" name = "time"
version = "0.3.36" version = "0.3.36"

View File

@@ -203,6 +203,7 @@ windows-sys = { workspace = true, features = [
# web: # web:
[target.'cfg(target_arch = "wasm32")'.dependencies] [target.'cfg(target_arch = "wasm32")'.dependencies]
bytemuck.workspace = true bytemuck.workspace = true
image = { workspace = true, features = ["png"] } # For copying images
js-sys = "0.3" js-sys = "0.3"
percent-encoding = "2.1" percent-encoding = "2.1"
wasm-bindgen.workspace = true wasm-bindgen.workspace = true
@@ -210,8 +211,10 @@ wasm-bindgen-futures.workspace = true
web-sys = { workspace = true, features = [ web-sys = { workspace = true, features = [
"BinaryType", "BinaryType",
"Blob", "Blob",
"BlobPropertyBag",
"Clipboard", "Clipboard",
"ClipboardEvent", "ClipboardEvent",
"ClipboardItem",
"CompositionEvent", "CompositionEvent",
"console", "console",
"CssStyleDeclaration", "CssStyleDeclaration",

View File

@@ -292,12 +292,15 @@ impl AppRunner {
} }
fn handle_platform_output(&self, platform_output: egui::PlatformOutput) { fn handle_platform_output(&self, platform_output: egui::PlatformOutput) {
#![allow(deprecated)]
#[cfg(feature = "web_screen_reader")] #[cfg(feature = "web_screen_reader")]
if self.egui_ctx.options(|o| o.screen_reader) { if self.egui_ctx.options(|o| o.screen_reader) {
super::screen_reader::speak(&platform_output.events_description()); super::screen_reader::speak(&platform_output.events_description());
} }
let egui::PlatformOutput { let egui::PlatformOutput {
commands,
cursor_icon, cursor_icon,
open_url, open_url,
copied_text, copied_text,
@@ -310,7 +313,22 @@ impl AppRunner {
request_discard_reasons: _, // handled by `Context::run` request_discard_reasons: _, // handled by `Context::run`
} = platform_output; } = 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); super::set_cursor_icon(cursor_icon);
if let Some(open) = open_url { if let Some(open) = open_url {
super::open_url(&open.url, open.new_tab); super::open_url(&open.url, open.new_tab);
} }

View File

@@ -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<image::RgbaImage, String> {
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<Vec<u8>, String> {
profiling::function_scope!();
let mut png_bytes: Vec<u8> = 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<web_sys::ClipboardItem, JsValue> {
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 { fn cursor_web_name(cursor: egui::CursorIcon) -> &'static str {
match cursor { match cursor {
egui::CursorIcon::Alias => "alias", egui::CursorIcon::Alias => "alias",

View File

@@ -36,11 +36,11 @@ android-game-activity = ["winit/android-game-activity"]
android-native-activity = ["winit/android-native-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`](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. ## Enable cut/copy/paste to OS clipboard.
## If disabled a clipboard will be simulated so you can still copy/paste within the egui app. ## 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. ## Enable opening links in a browser when an egui hyperlink is clicked.
links = ["webbrowser"] links = ["webbrowser"]
@@ -69,6 +69,8 @@ winit = { workspace = true, default-features = false }
# feature accesskit # feature accesskit
accesskit_winit = { version = "0.23", optional = true } accesskit_winit = { version = "0.23", optional = true }
bytemuck = { workspace = true, optional = true }
## Enable this when generating docs. ## Enable this when generating docs.
document-features = { workspace = true, optional = true } 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 } wayland-cursor = { version = "0.31.1", default-features = false, optional = true }
[target.'cfg(not(target_os = "android"))'.dependencies] [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",
] }

View File

@@ -82,7 +82,7 @@ impl Clipboard {
Some(self.clipboard.clone()) Some(self.clipboard.clone())
} }
pub fn set(&mut self, text: String) { pub fn set_text(&mut self, text: String) {
#[cfg(all( #[cfg(all(
any( any(
target_os = "linux", target_os = "linux",
@@ -108,6 +108,24 @@ impl Clipboard {
self.clipboard = text; 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")))] #[cfg(all(feature = "arboard", not(target_os = "android")))]

View File

@@ -190,7 +190,7 @@ impl State {
/// Places the text onto the clipboard. /// Places the text onto the clipboard.
pub fn set_clipboard_text(&mut self, text: String) { 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. /// 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) => { WindowEvent::Ime(ime) => {
if cfg!(target_os = "linux") { // on Mac even Cmd-C is pressed during ime, a `c` is pushed to Preedit.
// We ignore IME events on linux, because of https://github.com/emilk/egui/issues/5008 // So no need to check is_mac_cmd.
} else { //
// on Mac even Cmd-C is pressed during ime, a `c` is pushed to Preedit. // How winit produce `Ime::Enabled` and `Ime::Disabled` differs in MacOS
// So no need to check is_mac_cmd. // and Windows.
// //
// How winit produce `Ime::Enabled` and `Ime::Disabled` differs in MacOS // - On Windows, before and after each Commit will produce an Enable/Disabled
// and Windows. // event.
// // - On MacOS, only when user explicit enable/disable ime. No Disabled
// - On Windows, before and after each Commit will produce an Enable/Disabled // after Commit.
// event. //
// - On MacOS, only when user explicit enable/disable ime. No Disabled // We use input_method_editor_started to manually insert CompositionStart
// after Commit. // between Commits.
// match ime {
// We use input_method_editor_started to manually insert CompositionStart winit::event::Ime::Enabled => {
// between Commits. if cfg!(target_os = "linux") {
match ime { // This event means different things in X11 and Wayland, but we can just
winit::event::Ime::Enabled => { // ignore it and enable IME on the preedit event.
// See <https://github.com/rust-windowing/winit/issues/2498>
} else {
self.ime_event_enable(); self.ime_event_enable();
} }
winit::event::Ime::Preedit(text, Some(_cursor)) => { }
self.ime_event_enable(); winit::event::Ime::Preedit(text, Some(_cursor)) => {
self.egui_input self.ime_event_enable();
.events self.egui_input
.push(egui::Event::Ime(egui::ImeEvent::Preedit(text.clone()))); .events
} .push(egui::Event::Ime(egui::ImeEvent::Preedit(text.clone())));
winit::event::Ime::Commit(text) => { }
self.egui_input winit::event::Ime::Commit(text) => {
.events self.egui_input
.push(egui::Event::Ime(egui::ImeEvent::Commit(text.clone()))); .events
self.ime_event_disable(); .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::Disabled | winit::event::Ime::Preedit(_, None) => {
} self.ime_event_disable();
}; }
} };
EventResponse { EventResponse {
repaint: true, repaint: true,
@@ -820,9 +822,11 @@ impl State {
window: &Window, window: &Window,
platform_output: egui::PlatformOutput, platform_output: egui::PlatformOutput,
) { ) {
#![allow(deprecated)]
profiling::function_scope!(); profiling::function_scope!();
let egui::PlatformOutput { let egui::PlatformOutput {
commands,
cursor_icon, cursor_icon,
open_url, open_url,
copied_text, copied_text,
@@ -835,6 +839,20 @@ impl State {
request_discard_reasons: _, // `egui::Context::run` handles this request_discard_reasons: _, // `egui::Context::run` handles this
} = platform_output; } = 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); self.set_cursor_icon(window, cursor_icon);
if let Some(open_url) = open_url { if let Some(open_url) = open_url {
@@ -842,7 +860,7 @@ impl State {
} }
if !copied_text.is_empty() { if !copied_text.is_empty() {
self.clipboard.set(copied_text); self.clipboard.set_text(copied_text);
} }
let allow_ime = ime.is_some(); let allow_ime = ime.is_some();

View File

@@ -74,6 +74,10 @@ serde = ["dep:serde", "epaint/serde", "accesskit?/serde"]
## Change Vertex layout to be compatible with unity ## Change Vertex layout to be compatible with unity
unity = ["epaint/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] [dependencies]
emath = { workspace = true, default-features = false } emath = { workspace = true, default-features = false }

View File

@@ -20,7 +20,7 @@ use epaint::{Color32, Margin, Rect, Rounding, Shadow, Shape, Stroke};
/// ///
/// ## Dynamic color /// ## Dynamic color
/// If you want to change the color of the frame based on the response of /// 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| { /// # egui::__run_test_ui(|ui| {

View File

@@ -1419,6 +1419,12 @@ impl Context {
self.output_mut(|o| o.cursor_icon = cursor_icon); 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. /// Open an URL in a browser.
/// ///
/// Equivalent to: /// Equivalent to:
@@ -1428,24 +1434,25 @@ impl Context {
/// ctx.output_mut(|o| o.open_url = Some(open_url)); /// ctx.output_mut(|o| o.open_url = Some(open_url));
/// ``` /// ```
pub fn open_url(&self, open_url: crate::OpenUrl) { 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. /// Copy the given text to the system clipboard.
/// ///
/// Empty strings are ignored. /// Note that in web applications, the clipboard is only accessible in secure contexts (e.g.,
///
/// 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 /// HTTPS or localhost). If this method is used outside of a secure context, it will log an
/// error and do nothing. See <https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts>. /// error and do nothing. See <https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts>.
///
/// 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) { 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 <https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts>.
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`). /// Format the given shortcut in a human-readable way (e.g. `Ctrl+Shift+X`).

View File

@@ -79,6 +79,24 @@ pub struct IMEOutput {
pub cursor_rect: crate::Rect, 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. /// The non-rendering part of what egui emits each frame.
/// ///
/// You can access (and modify) this with [`crate::Context::output`]. /// You can access (and modify) this with [`crate::Context::output`].
@@ -87,10 +105,14 @@ pub struct IMEOutput {
#[derive(Default, Clone, PartialEq)] #[derive(Default, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub struct PlatformOutput { pub struct PlatformOutput {
/// Commands that the egui integration should execute at the end of a frame.
pub commands: Vec<OutputCommand>,
/// Set the cursor to this icon. /// Set the cursor to this icon.
pub cursor_icon: CursorIcon, pub cursor_icon: CursorIcon,
/// If set, open this url. /// If set, open this url.
#[deprecated = "Use `Context::open_url` instead"]
pub open_url: Option<OpenUrl>, pub open_url: Option<OpenUrl>,
/// If set, put this text in the system clipboard. Ignore if empty. /// 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, pub copied_text: String,
/// Events that may be useful to e.g. a screen reader. /// Events that may be useful to e.g. a screen reader.
@@ -162,7 +185,10 @@ impl PlatformOutput {
/// Add on new output. /// Add on new output.
pub fn append(&mut self, newer: Self) { pub fn append(&mut self, newer: Self) {
#![allow(deprecated)]
let Self { let Self {
mut commands,
cursor_icon, cursor_icon,
open_url, open_url,
copied_text, copied_text,
@@ -175,6 +201,7 @@ impl PlatformOutput {
mut request_discard_reasons, mut request_discard_reasons,
} = newer; } = newer;
self.commands.append(&mut commands);
self.cursor_icon = cursor_icon; self.cursor_icon = cursor_icon;
if open_url.is_some() { if open_url.is_some() {
self.open_url = open_url; self.open_url = open_url;
@@ -213,7 +240,7 @@ impl PlatformOutput {
/// What URL to open, and how. /// What URL to open, and how.
/// ///
/// Use with [`crate::Context::open_url`]. /// Use with [`crate::Context::open_url`].
#[derive(Clone, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub struct OpenUrl { pub struct OpenUrl {
pub url: String, pub url: String,
@@ -673,6 +700,7 @@ impl WidgetInfo {
WidgetType::DragValue => "drag value", WidgetType::DragValue => "drag value",
WidgetType::ColorButton => "color button", WidgetType::ColorButton => "color button",
WidgetType::ImageButton => "image button", WidgetType::ImageButton => "image button",
WidgetType::Image => "image",
WidgetType::CollapsingHeader => "collapsing header", WidgetType::CollapsingHeader => "collapsing header",
WidgetType::ProgressIndicator => "progress indicator", WidgetType::ProgressIndicator => "progress indicator",
WidgetType::Window => "window", WidgetType::Window => "window",

View File

@@ -482,7 +482,8 @@ pub use self::{
data::{ data::{
input::*, input::*,
output::{ output::{
self, CursorIcon, FullOutput, OpenUrl, PlatformOutput, UserAttentionType, WidgetInfo, self, CursorIcon, FullOutput, OpenUrl, OutputCommand, PlatformOutput,
UserAttentionType, WidgetInfo,
}, },
Key, UserData, Key, UserData,
}, },
@@ -665,6 +666,8 @@ pub enum WidgetType {
ImageButton, ImageButton,
Image,
CollapsingHeader, CollapsingHeader,
ProgressIndicator, ProgressIndicator,

View File

@@ -1017,6 +1017,7 @@ impl Response {
WidgetType::Button | WidgetType::ImageButton | WidgetType::CollapsingHeader => { WidgetType::Button | WidgetType::ImageButton | WidgetType::CollapsingHeader => {
Role::Button Role::Button
} }
WidgetType::Image => Role::Image,
WidgetType::Checkbox => Role::CheckBox, WidgetType::Checkbox => Role::CheckBox,
WidgetType::RadioButton => Role::RadioButton, WidgetType::RadioButton => Role::RadioButton,
WidgetType::RadioGroup => Role::RadioGroup, WidgetType::RadioGroup => Role::RadioGroup,

View File

@@ -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. /// 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). /// See [`crate::viewport`] for how to build new viewports (native windows).
/// ///
/// All coordinates are in logical points. /// 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)] #[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub enum ViewportCommand { pub enum ViewportCommand {

View File

@@ -344,6 +344,7 @@ impl Widget for Button<'_> {
image_rect, image_rect,
image.show_loading_spinner, image.show_loading_spinner,
&image_options, &image_options,
None,
); );
response = widgets::image::texture_load_result_response( response = widgets::image::texture_load_result_response(
&image.source(ui.ctx()), &image.source(ui.ctx()),

View File

@@ -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 emath::{Align, Float as _, Rot2};
use epaint::RectShape; use epaint::{
text::{LayoutJob, TextFormat, TextWrapping},
RectShape,
};
use crate::{ use crate::{
load::{Bytes, SizeHint, SizedTexture, TextureLoadResult, TexturePoll}, load::{Bytes, SizeHint, SizedTexture, TextureLoadResult, TexturePoll},
pos2, Align2, Color32, Context, Id, Mesh, Painter, Rect, Response, Rounding, Sense, Shape, pos2, Color32, Context, Id, Mesh, Painter, Rect, Response, Rounding, Sense, Shape, Spinner,
Spinner, Stroke, TextStyle, TextureOptions, Ui, Vec2, Widget, Stroke, TextStyle, TextureOptions, Ui, Vec2, Widget, WidgetInfo, WidgetType,
}; };
/// A widget which displays an image. /// A widget which displays an image.
@@ -51,6 +54,7 @@ pub struct Image<'a> {
sense: Sense, sense: Sense,
size: ImageSize, size: ImageSize,
pub(crate) show_loading_spinner: Option<bool>, pub(crate) show_loading_spinner: Option<bool>,
alt_text: Option<String>,
} }
impl<'a> Image<'a> { impl<'a> Image<'a> {
@@ -76,6 +80,7 @@ impl<'a> Image<'a> {
sense: Sense::hover(), sense: Sense::hover(),
size, size,
show_loading_spinner: None, show_loading_spinner: None,
alt_text: None,
} }
} }
@@ -255,6 +260,14 @@ impl<'a> Image<'a> {
self.show_loading_spinner = Some(show); self.show_loading_spinner = Some(show);
self 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<String>) -> Self {
self.alt_text = Some(label.into());
self
}
} }
impl<'a, T: Into<ImageSource<'a>>> From<T> for Image<'a> { impl<'a, T: Into<ImageSource<'a>>> From<T> for Image<'a> {
@@ -286,12 +299,12 @@ impl<'a> Image<'a> {
/// Returns the URI of the image. /// 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] #[inline]
pub fn uri(&self) -> Option<&str> { pub fn uri(&self) -> Option<&str> {
let uri = self.source.uri()?; 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) Some(gif_uri)
} else { } else {
Some(uri) Some(uri)
@@ -306,13 +319,15 @@ impl<'a> Image<'a> {
#[inline] #[inline]
pub fn source(&'a self, ctx: &Context) -> ImageSource<'a> { pub fn source(&'a self, ctx: &Context) -> ImageSource<'a> {
match &self.source { match &self.source {
ImageSource::Uri(uri) if is_gif_uri(uri) => { ImageSource::Uri(uri) if is_animated_image_uri(uri) => {
let frame_uri = encode_gif_uri(uri, gif_frame_index(ctx, uri)); let frame_uri =
encode_animated_image_uri(uri, animated_image_frame_index(ctx, uri));
ImageSource::Uri(Cow::Owned(frame_uri)) ImageSource::Uri(Cow::Owned(frame_uri))
} }
ImageSource::Bytes { uri, bytes } if is_gif_uri(uri) || has_gif_magic_header(bytes) => { ImageSource::Bytes { uri, bytes } if are_animated_image_bytes(bytes) => {
let frame_uri = encode_gif_uri(uri, gif_frame_index(ctx, uri)); let frame_uri =
encode_animated_image_uri(uri, animated_image_frame_index(ctx, uri));
ctx.include_bytes(uri.clone(), bytes.clone()); ctx.include_bytes(uri.clone(), bytes.clone());
ImageSource::Uri(Cow::Owned(frame_uri)) ImageSource::Uri(Cow::Owned(frame_uri))
} }
@@ -352,6 +367,7 @@ impl<'a> Image<'a> {
rect, rect,
self.show_loading_spinner, self.show_loading_spinner,
&self.image_options, &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 ui_size = self.calc_size(ui.available_size(), original_image_size);
let (rect, response) = ui.allocate_exact_size(ui_size, self.sense); 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) { if ui.is_rect_visible(rect) {
paint_texture_load_result( paint_texture_load_result(
ui, ui,
@@ -370,6 +391,7 @@ impl<'a> Widget for Image<'a> {
rect, rect,
self.show_loading_spinner, self.show_loading_spinner,
&self.image_options, &self.image_options,
self.alt_text.as_deref(),
); );
} }
texture_load_result_response(&self.source(ui.ctx()), &tlr, response) texture_load_result_response(&self.source(ui.ctx()), &tlr, response)
@@ -600,6 +622,7 @@ pub fn paint_texture_load_result(
rect: Rect, rect: Rect,
show_loading_spinner: Option<bool>, show_loading_spinner: Option<bool>,
options: &ImageOptions, options: &ImageOptions,
alt: Option<&str>,
) { ) {
match tlr { match tlr {
Ok(TexturePoll::Ready { texture }) => { Ok(TexturePoll::Ready { texture }) => {
@@ -614,12 +637,28 @@ pub fn paint_texture_load_result(
} }
Err(_) => { Err(_) => {
let font_id = TextStyle::Body.resolve(ui.style()); let font_id = TextStyle::Body.resolve(ui.style());
ui.painter().text( let mut job = LayoutJob {
rect.center(), wrap: TextWrapping::truncate_at_width(rect.width()),
Align2::CENTER_CENTER, halign: Align::Center,
..Default::default()
};
job.append(
"", "",
font_id, 0.0,
ui.visuals().error_fg_color, 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 #[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
fn encode_gif_uri(uri: &str, frame_index: usize) -> String { /// Stores the durations between each frame of an animated image
pub struct FrameDurations(Arc<Vec<Duration>>);
impl FrameDurations {
pub fn new(durations: Vec<Duration>) -> 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}") format!("{uri}#{frame_index}")
} }
/// extracts uri and frame index /// Extracts uri and frame index
/// # Errors /// # Errors
/// Will return `Err` if `uri` does not match pattern {uri}-{frame_index} /// 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 let (uri, index) = uri
.rsplit_once('#') .rsplit_once('#')
.ok_or("Failed to find index separator '#'")?; .ok_or("Failed to find index separator '#'")?;
let index: usize = index let index: usize = index.parse().map_err(|_err| {
.parse() format!("Failed to parse animated image frame index: {index:?} is not an integer")
.map_err(|_err| format!("Failed to parse gif frame index: {index:?} is not an integer"))?; })?;
Ok((uri, index)) Ok((uri, index))
} }
/// checks if uri is a gif file /// Calculates at which frame the animated image is
fn is_gif_uri(uri: &str) -> bool { fn animated_image_frame_index(ctx: &Context, uri: &str) -> usize {
uri.ends_with(".gif") || uri.contains(".gif#") let now = ctx.input(|input| Duration::from_secs_f64(input.time));
}
/// checks if bytes are gifs let durations: Option<FrameDurations> = ctx.data(|data| data.get_temp(Id::new(uri)));
pub fn has_gif_magic_header(bytes: &[u8]) -> bool {
bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a")
}
/// 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<GifFrameDurations> = ctx.data(|data| data.get_temp(Id::new(uri)));
if let Some(durations) = durations { 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 pos_ms = now.as_millis() % frames.as_millis().max(1);
let mut cumulative_ms = 0; 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(); cumulative_ms += duration.as_millis();
if pos_ms < cumulative_ms { if pos_ms < cumulative_ms {
let ms_until_next_frame = cumulative_ms - pos_ms; let ms_until_next_frame = cumulative_ms - pos_ms;
ctx.request_repaint_after(Duration::from_millis(ms_until_next_frame as u64)); ctx.request_repaint_after(Duration::from_millis(ms_until_next_frame as u64));
return i; return index;
} }
} }
0 0
} else { } else {
0 0
} }
} }
#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)] /// Checks if uri is a gif file
/// Stores the durations between each frame of a gif fn is_gif_uri(uri: &str) -> bool {
pub struct GifFrameDurations(pub Arc<Vec<Duration>>); 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)
}

View File

@@ -11,6 +11,7 @@ pub struct ImageButton<'a> {
sense: Sense, sense: Sense,
frame: bool, frame: bool,
selected: bool, selected: bool,
alt_text: Option<String>,
} }
impl<'a> ImageButton<'a> { impl<'a> ImageButton<'a> {
@@ -20,6 +21,7 @@ impl<'a> ImageButton<'a> {
sense: Sense::click(), sense: Sense::click(),
frame: true, frame: true,
selected: false, selected: false,
alt_text: None,
} }
} }
@@ -87,7 +89,11 @@ impl<'a> Widget for ImageButton<'a> {
let padded_size = image_size + 2.0 * padding; let padded_size = image_size + 2.0 * padding;
let (rect, response) = ui.allocate_exact_size(padded_size, self.sense); 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) { if ui.is_rect_visible(rect) {
let (expansion, rounding, fill, stroke) = if self.selected { 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_rect = image_rect.expand2(expansion); // can make it blurry, so let's not
let image_options = self.image.image_options().clone(); 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: // Draw frame outline:
ui.painter() ui.painter()

View File

@@ -28,8 +28,8 @@ pub use self::{
drag_value::DragValue, drag_value::DragValue,
hyperlink::{Hyperlink, Link}, hyperlink::{Hyperlink, Link},
image::{ image::{
decode_gif_uri, has_gif_magic_header, paint_texture_at, GifFrameDurations, Image, ImageFit, decode_animated_image_uri, has_gif_magic_header, has_webp_header, paint_texture_at,
ImageOptions, ImageSize, ImageSource, FrameDurations, Image, ImageFit, ImageOptions, ImageSize, ImageSource,
}, },
image_button::ImageButton, image_button::ImageButton,
label::Label, label::Label,

View File

@@ -14,6 +14,7 @@ pub struct ImageViewer {
fit: ImageFit, fit: ImageFit,
maintain_aspect_ratio: bool, maintain_aspect_ratio: bool,
max_size: Vec2, max_size: Vec2,
alt_text: String,
} }
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Clone, Copy, PartialEq, Eq)]
@@ -44,6 +45,7 @@ impl Default for ImageViewer {
fit: ImageFit::Fraction(Vec2::splat(1.0)), fit: ImageFit::Fraction(Vec2::splat(1.0)),
maintain_aspect_ratio: true, maintain_aspect_ratio: true,
max_size: Vec2::splat(2048.0), 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.label("Aspect ratio is maintained by scaling both sides as necessary");
ui.checkbox(&mut self.maintain_aspect_ratio, "Maintain aspect ratio"); 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 // forget all images
if ui.button("Forget all images").clicked() { if ui.button("Forget all images").clicked() {
ui.ctx().forget_all_images(); 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.maintain_aspect_ratio(self.maintain_aspect_ratio);
image = image.max_size(self.max_size); 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); ui.add_sized(ui.available_size(), image);
}); });

View File

@@ -14,6 +14,7 @@ impl crate::Demo for About {
.default_height(480.0) .default_height(480.0)
.open(open) .open(open)
.resizable([true, false]) .resizable([true, false])
.scroll(false)
.show(ctx, |ui| { .show(ctx, |ui| {
use crate::View as _; use crate::View as _;
self.ui(ui); 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.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"); ui.heading("Immediate mode");
about_immediate_mode(ui); about_immediate_mode(ui);
ui.add_space(12.0); // ui.separator(); ui.add_space(12.0);
ui.heading("Links"); ui.heading("Links");
links(ui); links(ui);
@@ -50,7 +53,10 @@ impl crate::View for About {
ui.spacing_mut().item_spacing.x = 0.0; ui.spacing_mut().item_spacing.x = 0.0;
ui.label("egui development is sponsored by "); ui.label("egui development is sponsored by ");
ui.hyperlink_to("Rerun.io", "https://www.rerun.io/"); 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); ui.add_space(12.0);
@@ -94,12 +100,12 @@ fn about_immediate_mode(ui: &mut egui::Ui) {
fn links(ui: &mut egui::Ui) { fn links(ui: &mut egui::Ui) {
use egui::special_emojis::{GITHUB, TWITTER}; use egui::special_emojis::{GITHUB, TWITTER};
ui.hyperlink_to( ui.hyperlink_to(
format!("{GITHUB} egui on GitHub"), format!("{GITHUB} github.com/emilk/egui"),
"https://github.com/emilk/egui", "https://github.com/emilk/egui",
); );
ui.hyperlink_to( ui.hyperlink_to(
format!("{TWITTER} @ernerfeldt"), format!("{TWITTER} @ernerfeldt"),
"https://twitter.com/ernerfeldt", "https://twitter.com/ernerfeldt",
); );
ui.hyperlink_to("egui documentation", "https://docs.rs/egui/"); ui.hyperlink_to("📓 egui documentation", "https://docs.rs/egui/");
} }

View File

@@ -84,9 +84,8 @@ impl CodeExample {
ui.horizontal(|ui| { ui.horizontal(|ui| {
let font_id = egui::TextStyle::Monospace.resolve(ui.style()); let font_id = egui::TextStyle::Monospace.resolve(ui.style());
let indentation = 8.0 * ui.fonts(|f| f.glyph_width(&font_id, ' ')); let indentation = 2.0 * 4.0 * ui.fonts(|f| f.glyph_width(&font_id, ' '));
let item_spacing = ui.spacing_mut().item_spacing; ui.add_space(indentation);
ui.add_space(indentation - item_spacing.x);
egui::Grid::new("code_samples") egui::Grid::new("code_samples")
.striped(true) .striped(true)
@@ -112,7 +111,7 @@ impl crate::Demo for CodeExample {
.min_width(375.0) .min_width(375.0)
.default_size([390.0, 500.0]) .default_size([390.0, 500.0])
.scroll(false) .scroll(false)
.resizable([true, false]) .resizable([true, false]) // resizable so we can shrink if the text edit grows
.show(ctx, |ui| self.ui(ui)); .show(ctx, |ui| self.ui(ui));
} }
} }
@@ -120,7 +119,7 @@ impl crate::Demo for CodeExample {
impl crate::View for CodeExample { impl crate::View for CodeExample {
fn ui(&mut self, ui: &mut egui::Ui) { fn ui(&mut self, ui: &mut egui::Ui) {
ui.scope(|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); self.code(ui);
}); });

View File

@@ -1,6 +1,6 @@
use std::collections::BTreeSet; use std::collections::BTreeSet;
use egui::{Context, Modifiers, NumExt as _, ScrollArea, Ui}; use egui::{Context, Modifiers, ScrollArea, Ui};
use super::About; use super::About;
use crate::is_mobile; use crate::is_mobile;
@@ -9,73 +9,17 @@ use crate::View;
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] struct DemoGroup {
#[cfg_attr(feature = "serde", serde(default))]
struct Demos {
#[cfg_attr(feature = "serde", serde(skip))]
demos: Vec<Box<dyn Demo>>, demos: Vec<Box<dyn Demo>>,
open: BTreeSet<String>,
} }
impl Default for Demos { impl DemoGroup {
fn default() -> Self { pub fn new(demos: Vec<Box<dyn Demo>>) -> Self {
Self::from_demos(vec![ Self { demos }
Box::<super::paint_bezier::PaintBezier>::default(),
Box::<super::code_editor::CodeEditor>::default(),
Box::<super::code_example::CodeExample>::default(),
Box::<super::context_menu::ContextMenus>::default(),
Box::<super::dancing_strings::DancingStrings>::default(),
Box::<super::drag_and_drop::DragAndDropDemo>::default(),
Box::<super::extra_viewport::ExtraViewport>::default(),
Box::<super::font_book::FontBook>::default(),
Box::<super::frame_demo::FrameDemo>::default(),
Box::<super::highlighting::Highlighting>::default(),
Box::<super::interactive_container::InteractiveContainerDemo>::default(),
Box::<super::MiscDemoWindow>::default(),
Box::<super::modals::Modals>::default(),
Box::<super::multi_touch::MultiTouch>::default(),
Box::<super::painting::Painting>::default(),
Box::<super::pan_zoom::PanZoom>::default(),
Box::<super::panels::Panels>::default(),
Box::<super::screenshot::Screenshot>::default(),
Box::<super::scrolling::Scrolling>::default(),
Box::<super::sliders::Sliders>::default(),
Box::<super::strip_demo::StripDemo>::default(),
Box::<super::table_demo::TableDemo>::default(),
Box::<super::text_edit::TextEditDemo>::default(),
Box::<super::text_layout::TextLayoutDemo>::default(),
Box::<super::tooltips::Tooltips>::default(),
Box::<super::undo_redo::UndoRedoDemo>::default(),
Box::<super::widget_gallery::WidgetGallery>::default(),
Box::<super::window_options::WindowOptions>::default(),
])
}
}
impl Demos {
pub fn from_demos(demos: Vec<Box<dyn Demo>>) -> 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) { pub fn checkboxes(&mut self, ui: &mut Ui, open: &mut BTreeSet<String>) {
let Self { demos, open } = self; let Self { demos } = self;
for demo in demos { for demo in demos {
if demo.is_enabled(ui.ctx()) { if demo.is_enabled(ui.ctx()) {
let mut is_open = open.contains(demo.name()); let mut is_open = open.contains(demo.name());
@@ -85,8 +29,8 @@ impl Demos {
} }
} }
pub fn windows(&mut self, ctx: &Context) { pub fn windows(&mut self, ctx: &Context, open: &mut BTreeSet<String>) {
let Self { demos, open } = self; let Self { demos } = self;
for demo in demos { for demo in demos {
let mut is_open = open.contains(demo.name()); let mut is_open = open.contains(demo.name());
demo.show(ctx, &mut is_open); 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<Box<dyn Demo>>,
open: BTreeSet<String>,
}
impl Default for Tests {
fn default() -> Self {
Self::from_demos(vec![
Box::<super::tests::CursorTest>::default(),
Box::<super::tests::GridTest>::default(),
Box::<super::tests::IdTest>::default(),
Box::<super::tests::InputEventHistory>::default(),
Box::<super::tests::InputTest>::default(),
Box::<super::tests::LayoutTest>::default(),
Box::<super::tests::ManualLayoutTest>::default(),
Box::<super::tests::WindowResizeTest>::default(),
])
}
}
impl Tests {
pub fn from_demos(demos: Vec<Box<dyn Demo>>) -> 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<String>, key: &'static str, is_open: bool) { fn set_open(open: &mut BTreeSet<String>, key: &'static str, is_open: bool) {
if is_open { if is_open {
if !open.contains(key) { if !open.contains(key) {
@@ -166,23 +51,132 @@ fn set_open(open: &mut BTreeSet<String>, 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::<super::paint_bezier::PaintBezier>::default(),
Box::<super::code_editor::CodeEditor>::default(),
Box::<super::code_example::CodeExample>::default(),
Box::<super::context_menu::ContextMenus>::default(),
Box::<super::dancing_strings::DancingStrings>::default(),
Box::<super::drag_and_drop::DragAndDropDemo>::default(),
Box::<super::extra_viewport::ExtraViewport>::default(),
Box::<super::font_book::FontBook>::default(),
Box::<super::frame_demo::FrameDemo>::default(),
Box::<super::highlighting::Highlighting>::default(),
Box::<super::interactive_container::InteractiveContainerDemo>::default(),
Box::<super::MiscDemoWindow>::default(),
Box::<super::modals::Modals>::default(),
Box::<super::multi_touch::MultiTouch>::default(),
Box::<super::painting::Painting>::default(),
Box::<super::pan_zoom::PanZoom>::default(),
Box::<super::panels::Panels>::default(),
Box::<super::screenshot::Screenshot>::default(),
Box::<super::scrolling::Scrolling>::default(),
Box::<super::sliders::Sliders>::default(),
Box::<super::strip_demo::StripDemo>::default(),
Box::<super::table_demo::TableDemo>::default(),
Box::<super::text_edit::TextEditDemo>::default(),
Box::<super::text_layout::TextLayoutDemo>::default(),
Box::<super::tooltips::Tooltips>::default(),
Box::<super::undo_redo::UndoRedoDemo>::default(),
Box::<super::widget_gallery::WidgetGallery>::default(),
Box::<super::window_options::WindowOptions>::default(),
]),
tests: DemoGroup::new(vec![
Box::<super::tests::ClipboardTest>::default(),
Box::<super::tests::CursorTest>::default(),
Box::<super::tests::GridTest>::default(),
Box::<super::tests::IdTest>::default(),
Box::<super::tests::InputEventHistory>::default(),
Box::<super::tests::InputTest>::default(),
Box::<super::tests::LayoutTest>::default(),
Box::<super::tests::ManualLayoutTest>::default(),
Box::<super::tests::WindowResizeTest>::default(),
]),
}
}
}
impl DemoGroups {
pub fn checkboxes(&mut self, ui: &mut Ui, open: &mut BTreeSet<String>) {
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<String>) {
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. /// 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", derive(serde::Deserialize, serde::Serialize))]
#[cfg_attr(feature = "serde", serde(default))] #[cfg_attr(feature = "serde", serde(default))]
pub struct DemoWindows { pub struct DemoWindows {
about_is_open: bool, #[cfg_attr(feature = "serde", serde(skip))]
about: About, groups: DemoGroups,
demos: Demos,
tests: Tests, open: BTreeSet<String>,
} }
impl Default for DemoWindows { impl Default for DemoWindows {
fn default() -> Self { 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 { Self {
about_is_open: true, groups: Default::default(),
about: Default::default(), open,
demos: Default::default(),
tests: Default::default(),
} }
} }
} }
@@ -197,36 +191,35 @@ impl DemoWindows {
} }
} }
fn mobile_ui(&mut self, ctx: &Context) { fn about_is_open(&self) -> bool {
if self.about_is_open { self.open.contains(About::default().name())
let screen_size = ctx.input(|i| i.screen_rect.size()); }
let default_width = (screen_size.x - 32.0).at_most(400.0);
fn mobile_ui(&mut self, ctx: &Context) {
if self.about_is_open() {
let mut close = false; let mut close = false;
egui::Window::new(self.about.name()) egui::CentralPanel::default().show(ctx, |ui| {
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) egui::ScrollArea::vertical()
.default_width(default_width) .auto_shrink(false)
.default_height(ctx.available_rect().height() - 46.0) .show(ui, |ui| {
.vscroll(true) self.groups.about.ui(ui);
.open(&mut self.about_is_open) ui.add_space(12.0);
.resizable(false) ui.vertical_centered_justified(|ui| {
.collapsible(false) if ui
.show(ctx, |ui| { .button(egui::RichText::new("Continue to the demo!").size(20.0))
self.about.ui(ui); .clicked()
ui.add_space(12.0); {
ui.vertical_centered_justified(|ui| { close = true;
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 { } else {
self.mobile_top_bar(ctx); 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); self.groups.windows(ctx, &mut self.open);
}
/// 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);
} }
fn demo_list_ui(&mut self, ui: &mut egui::Ui) { fn demo_list_ui(&mut self, ui: &mut egui::Ui) {
ScrollArea::vertical().show(ui, |ui| { ScrollArea::vertical().show(ui, |ui| {
ui.with_layout(egui::Layout::top_down_justified(egui::Align::LEFT), |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(); ui.separator();
self.demos.checkboxes(ui);
ui.separator();
self.tests.checkboxes(ui);
ui.separator();
if ui.button("Organize windows").clicked() { if ui.button("Organize windows").clicked() {
ui.ctx().memory_mut(|mem| mem.reset_areas()); ui.ctx().memory_mut(|mem| mem.reset_areas());
} }
@@ -382,29 +362,29 @@ fn file_menu_button(ui: &mut Ui) {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::demo::demo_app_windows::Demos; use crate::{demo::demo_app_windows::DemoGroups, Demo};
use egui::Vec2; use egui::Vec2;
use egui_kittest::kittest::Queryable; use egui_kittest::kittest::Queryable;
use egui_kittest::{Harness, SnapshotOptions}; use egui_kittest::{Harness, SnapshotOptions};
#[test] #[test]
fn demos_should_match_snapshot() { fn demos_should_match_snapshot() {
let demos = Demos::default(); let demos = DemoGroups::default().demos;
let mut errors = Vec::new(); let mut errors = Vec::new();
for mut demo in demos.demos { 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 // Remove the emoji from the demo name
let name = demo let name = demo
.name() .name()
.split_once(' ') .split_once(' ')
.map_or(demo.name(), |(_, name)| name); .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| { let mut harness = Harness::new(|ctx| {
demo.show(ctx, &mut true); demo.show(ctx, &mut true);
}); });

View File

@@ -85,7 +85,7 @@ impl crate::View for FontBook {
ui.horizontal_wrapped(|ui| { ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = egui::Vec2::splat(2.0); 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() if filter.is_empty()
|| glyph_info.name.contains(filter) || glyph_info.name.contains(filter)
|| *filter == chr.to_string() || *filter == chr.to_string()
@@ -96,13 +96,9 @@ impl crate::View for FontBook {
.frame(false); .frame(false);
let tooltip_ui = |ui: &mut egui::Ui| { let tooltip_ui = |ui: &mut egui::Ui| {
ui.label( let font_id = self.font_id.clone();
egui::RichText::new(chr.to_string()).font(self.font_id.clone()),
); char_info_ui(ui, chr, glyph_info, font_id);
ui.label(format!(
"{}\nU+{:X}\n\nFound in: {:?}\n\nClick to copy",
glyph_info.name, chr as u32, glyph_info.fonts
));
}; };
if ui.add(button).on_hover_ui(tooltip_ui).clicked() { 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<char, GlyphInfo> { fn available_characters(ui: &egui::Ui, family: egui::FontFamily) -> BTreeMap<char, GlyphInfo> {
ui.fonts(|f| { ui.fonts(|f| {
f.lock() f.lock()

View File

@@ -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!());
});
}
}

View File

@@ -1,3 +1,4 @@
mod clipboard_test;
mod cursor_test; mod cursor_test;
mod grid_test; mod grid_test;
mod id_test; mod id_test;
@@ -7,6 +8,7 @@ mod layout_test;
mod manual_layout_test; mod manual_layout_test;
mod window_resize_test; mod window_resize_test;
pub use clipboard_test::ClipboardTest;
pub use cursor_test::CursorTest; pub use cursor_test::CursorTest;
pub use grid_test::GridTest; pub use grid_test::GridTest;
pub use id_test::IdTest; pub use id_test::IdTest;

View File

@@ -50,7 +50,7 @@ impl crate::Demo for WidgetGallery {
fn show(&mut self, ctx: &egui::Context, open: &mut bool) { fn show(&mut self, ctx: &egui::Context, open: &mut bool) {
egui::Window::new(self.name()) egui::Window::new(self.name())
.open(open) .open(open)
.resizable([true, false]) .resizable([true, false]) // resizable so we can shrink if the text edit grows
.default_width(280.0) .default_width(280.0)
.show(ctx, |ui| { .show(ctx, |ui| {
use crate::View as _; use crate::View as _;
@@ -254,7 +254,7 @@ impl WidgetGallery {
ui.end_row(); ui.end_row();
ui.hyperlink_to( ui.hyperlink_to(
"Custom widget:", "Custom widget",
super::toggle_switch::url_to_file_source_code(), super::toggle_switch::url_to_file_source_code(),
); );
ui.add(super::toggle_switch::toggle(boolean)).on_hover_text( ui.add(super::toggle_switch::toggle(boolean)).on_hover_text(
@@ -274,10 +274,9 @@ fn doc_link_label_with_crate<'a>(
title: &'a str, title: &'a str,
search_term: &'a str, search_term: &'a str,
) -> impl egui::Widget + 'a { ) -> impl egui::Widget + 'a {
let label = format!("{title}:");
let url = format!("https://docs.rs/{crate_name}?search={search_term}"); let url = format!("https://docs.rs/{crate_name}?search={search_term}");
move |ui: &mut egui::Ui| { 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.horizontal_wrapped(|ui| {
ui.label("Search egui docs for"); ui.label("Search egui docs for");
ui.code(search_term); ui.code(search_term);

View File

@@ -1,7 +1,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use egui::{ 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, Color32, FontId, Image, Mesh, Pos2, Rect, Response, Rgba, RichText, Sense, Shape, Stroke,
TextureHandle, TextureOptions, Ui, Vec2, TextureHandle, TextureOptions, Ui, Vec2,
}; };

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b8ca5a27491c0589a97e43a70bc10dc52778d25ca3f7e7c895dbbbb784adfcfa oid sha256:0a1099b85a1aaf20f3f1e091bc68259f811737feaefdfcc12acd067eca8f9117
size 33245 size 27083

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e640606207265b4f040f793b0ffb989504b6a98b89e95e77a9a9d3e3abc9327a oid sha256:6969c6da67ea6cc7ebbbd7a2cc1cb13d4720befe28126367cbf2b2679d037674
size 80933 size 82363

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d122b1a995e691b5049c57d65c9f222a5f1639b1e4f6f96f91823444339693cc oid sha256:b3dc1bf9a59007a6ad0fb66a345d6cf272bd8bdcd26b10dbf411c1280e62b6fc
size 160540 size 158285

View File

@@ -31,7 +31,7 @@ rustdoc-args = ["--generate-link-to-definition"]
default = ["dep:mime_guess2"] default = ["dep:mime_guess2"]
## Shorthand for enabling the different types of image loaders (`file`, `http`, `image`, `svg`). ## 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. ## Enable [`DatePickerButton`] widget.
datepicker = ["chrono"] datepicker = ["chrono"]
@@ -42,6 +42,9 @@ file = ["dep:mime_guess2"]
## Support loading gif images. ## Support loading gif images.
gif = ["image", "image/gif"] gif = ["image", "image/gif"]
## Support loading webp images.
webp = ["image", "image/webp"]
## Add support for loading images via HTTP. ## Add support for loading images via HTTP.
http = ["dep:ehttp"] http = ["dep:ehttp"]

View File

@@ -84,6 +84,12 @@ pub fn install_image_loaders(ctx: &egui::Context) {
log::trace!("installed GifLoader"); 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")] #[cfg(feature = "svg")]
if !ctx.is_loader_installed(self::svg_loader::SvgLoader::ID) { if !ctx.is_loader_installed(self::svg_loader::SvgLoader::ID) {
ctx.add_image_loader(std::sync::Arc::new(self::svg_loader::SvgLoader::default())); ctx.add_image_loader(std::sync::Arc::new(self::svg_loader::SvgLoader::default()));
@@ -113,3 +119,5 @@ mod gif_loader;
mod image_loader; mod image_loader;
#[cfg(feature = "svg")] #[cfg(feature = "svg")]
mod svg_loader; mod svg_loader;
#[cfg(feature = "webp")]
mod webp_loader;

View File

@@ -1,9 +1,9 @@
use ahash::HashMap; use ahash::HashMap;
use egui::{ use egui::{
decode_gif_uri, has_gif_magic_header, decode_animated_image_uri, has_gif_magic_header,
load::{BytesPoll, ImageLoadResult, ImageLoader, ImagePoll, LoadError, SizeHint}, load::{BytesPoll, ImageLoadResult, ImageLoader, ImagePoll, LoadError, SizeHint},
mutex::Mutex, mutex::Mutex,
ColorImage, GifFrameDurations, Id, ColorImage, FrameDurations, Id,
}; };
use image::AnimationDecoder as _; use image::AnimationDecoder as _;
use std::{io::Cursor, mem::size_of, sync::Arc, time::Duration}; 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)] #[derive(Debug, Clone)]
pub struct AnimatedImage { pub struct AnimatedImage {
frames: Vec<Arc<ColorImage>>, frames: Vec<Arc<ColorImage>>,
frame_durations: GifFrameDurations, frame_durations: FrameDurations,
} }
impl AnimatedImage { impl AnimatedImage {
@@ -35,7 +35,7 @@ impl AnimatedImage {
} }
Ok(Self { Ok(Self {
frames: images, 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 { fn load(&self, ctx: &egui::Context, frame_uri: &str, _: SizeHint) -> ImageLoadResult {
let (image_uri, frame_index) = 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(); let mut cache = self.cache.lock();
if let Some(entry) = cache.get(image_uri).cloned() { if let Some(entry) = cache.get(image_uri).cloned() {
match entry { match entry {

View File

@@ -19,7 +19,10 @@ impl ImageCrateLoader {
} }
fn is_supported_uri(uri: &str) -> bool { 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 // `true` because if there's no extension, assume that we support it
return true; return true;
}; };

View File

@@ -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<ColorImage>),
Animated(AnimatedImage),
}
impl WebP {
fn load(data: &[u8]) -> Result<Self, String> {
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<ColorImage> {
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::<Self>()
+ match self {
Self::Static(image) => image.pixels.len() * size_of::<egui::Color32>(),
Self::Animated(animation) => animation.byte_len(),
}
}
}
#[derive(Debug, Clone)]
pub struct AnimatedImage {
frames: Vec<Arc<ColorImage>>,
frame_durations: FrameDurations,
}
impl AnimatedImage {
pub fn byte_len(&self) -> usize {
size_of::<Self>()
+ self
.frames
.iter()
.map(|image| {
image.pixels.len() * size_of::<egui::Color32>() + size_of::<Duration>()
})
.sum::<usize>()
}
pub fn get_image_by_index(&self, index: usize) -> Arc<ColorImage> {
self.frames[index % self.frames.len()].clone()
}
}
type Entry = Result<WebP, String>;
#[derive(Default)]
pub struct WebPLoader {
cache: Mutex<HashMap<String, Entry>>,
}
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()
}
}

View File

@@ -1,4 +1,4 @@
use egui::Button; use egui::{Button, Image, Vec2, Widget};
use egui_kittest::{kittest::Queryable, Harness}; use egui_kittest::{kittest::Queryable, Harness};
#[test] #[test]
@@ -27,3 +27,19 @@ pub fn focus_should_skip_over_disabled_buttons() {
let button_1 = harness.get_by_label("Button 1"); let button_1 = harness.get_by_label("Button 1");
assert!(button_1.is_focused()); 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");
}

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:31faeb4e5f488b8bcee5e090accd326d7e43b264e81768ae7c1907e3b6d0f739
size 2121

View File

@@ -66,6 +66,10 @@ serde = ["dep:serde", "ahash/serde", "emath/serde", "ecolor/serde"]
## Change Vertex layout to be compatible with unity ## Change Vertex layout to be compatible with unity
unity = [] unity = []
## Override and disable the unity feature
## This exists, so that when testing with --all-features, snapshots render correctly.
_override_unity = []
[dependencies] [dependencies]
emath.workspace = true emath.workspace = true
ecolor.workspace = true ecolor.workspace = true

View File

@@ -143,37 +143,6 @@ impl ColorImage {
bytemuck::cast_slice_mut(&mut self.pixels) 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<f32>) -> 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. /// Create a [`ColorImage`] from flat RGB data.
/// ///
/// This is what you want to use after having loaded an image file (and if /// 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 { pub fn height(&self) -> usize {
self.size[1] 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<f32>) -> 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 { impl std::ops::Index<(usize, usize)> for ColorImage {

View File

@@ -6,7 +6,7 @@ use emath::{Pos2, Rect, Rot2, TSTransform, Vec2};
/// Should be friendly to send to GPU as is. /// Should be friendly to send to GPU as is.
#[repr(C)] #[repr(C)]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] #[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 = "serde", derive(serde::Deserialize, serde::Serialize))]
#[cfg_attr(feature = "bytemuck", derive(bytemuck::Pod, bytemuck::Zeroable))] #[cfg_attr(feature = "bytemuck", derive(bytemuck::Pod, bytemuck::Zeroable))]
pub struct Vertex { pub struct Vertex {
@@ -25,7 +25,7 @@ pub struct Vertex {
#[repr(C)] #[repr(C)]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] #[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 = "serde", derive(serde::Deserialize, serde::Serialize))]
#[cfg_attr(feature = "bytemuck", derive(bytemuck::Pod, bytemuck::Zeroable))] #[cfg_attr(feature = "bytemuck", derive(bytemuck::Pod, bytemuck::Zeroable))]
pub struct Vertex { pub struct Vertex {

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:12eb9463cda6c2b1a160f085324f1afdfc5ced9ff0857df117030d8771259e5e oid sha256:a836741d52e1972b2047cefaabf59f601637d430d4b41bf6407ebda4f7931dac
size 303453 size 273450

Binary file not shown.

After

Width:  |  Height:  |  Size: 480 KiB

View File

@@ -6,7 +6,7 @@ use eframe::egui;
fn main() -> eframe::Result { fn main() -> eframe::Result {
env_logger::init(); // Log to stderr (if you run with `RUST_LOG=debug`). env_logger::init(); // Log to stderr (if you run with `RUST_LOG=debug`).
let options = eframe::NativeOptions { 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() ..Default::default()
}; };
eframe::run_native( eframe::run_native(
@@ -27,11 +27,16 @@ impl eframe::App for MyApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
egui::CentralPanel::default().show(ctx, |ui| { egui::CentralPanel::default().show(ctx, |ui| {
egui::ScrollArea::both().show(ui, |ui| { egui::ScrollArea::both().show(ui, |ui| {
ui.image(egui::include_image!("ferris.gif")); ui.image(egui::include_image!("cat.webp"))
ui.add( .on_hover_text_at_pointer("WebP");
egui::Image::new("https://picsum.photos/seed/1.759706314/1024").rounding(10.0), ui.image(egui::include_image!("ferris.gif"))
); .on_hover_text_at_pointer("Gif");
ui.image(egui::include_image!("ferris.svg")); 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);
}); });
}); });
} }

View File

@@ -3,8 +3,12 @@ set -eu
script_path=$( cd "$(dirname "${BASH_SOURCE[0]}")" ; pwd -P ) script_path=$( cd "$(dirname "${BASH_SOURCE[0]}")" ; pwd -P )
cd "$script_path/.." cd "$script_path/.."
set -x
# Pre-requisites: # Pre-requisites:
rustup target add wasm32-unknown-unknown rustup target add wasm32-unknown-unknown
# For generating JS bindings: # 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