1
0
mirror of https://github.com/emilk/egui.git synced 2026-06-27 07:03:14 -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
- 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

View File

@@ -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",
}

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.
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.

View File

@@ -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"

View File

@@ -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",

View File

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

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 {
match cursor {
egui::CursorIcon::Alias => "alias",

View File

@@ -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",
] }

View File

@@ -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")))]

View File

@@ -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 <https://github.com/rust-windowing/winit/issues/2498>
} 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();

View File

@@ -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 }

View File

@@ -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| {

View File

@@ -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 <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) {
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`).

View File

@@ -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<OutputCommand>,
/// 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<OpenUrl>,
/// 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",

View File

@@ -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,

View File

@@ -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,

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.
///
/// 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 {

View File

@@ -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()),

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 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<bool>,
alt_text: Option<String>,
}
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<String>) -> Self {
self.alt_text = Some(label.into());
self
}
}
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.
///
/// 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<bool>,
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<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}")
}
/// 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<FrameDurations> = 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<GifFrameDurations> = 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<Vec<Duration>>);
/// 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)
}

View File

@@ -11,6 +11,7 @@ pub struct ImageButton<'a> {
sense: Sense,
frame: bool,
selected: bool,
alt_text: Option<String>,
}
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()

View File

@@ -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,

View File

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

View File

@@ -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/");
}

View File

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

View File

@@ -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<Box<dyn Demo>>,
open: BTreeSet<String>,
}
impl Default for Demos {
fn default() -> Self {
Self::from_demos(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(),
])
}
}
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 }
impl DemoGroup {
pub fn new(demos: Vec<Box<dyn Demo>>) -> 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<String>) {
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<String>) {
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<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) {
if is_open {
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.
#[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<String>,
}
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);
});

View File

@@ -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<char, GlyphInfo> {
ui.fonts(|f| {
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 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;

View File

@@ -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);

View File

@@ -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,
};

View File

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

View File

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

View File

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

View File

@@ -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"]

View File

@@ -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;

View File

@@ -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<Arc<ColorImage>>,
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 {

View File

@@ -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;
};

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};
#[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");
}

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
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

View File

@@ -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<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.
///
/// 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<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 {

View File

@@ -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 {

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:12eb9463cda6c2b1a160f085324f1afdfc5ced9ff0857df117030d8771259e5e
size 303453
oid sha256:a836741d52e1972b2047cefaabf59f601637d430d4b41bf6407ebda4f7931dac
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 {
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);
});
});
}

View File

@@ -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