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:
6
.github/workflows/rust.yml
vendored
6
.github/workflows/rust.yml
vendored
@@ -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
|
||||
|
||||
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
39
Cargo.lock
39
Cargo.lock
@@ -240,11 +240,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df099ccb16cd014ff054ac1bf392c67feeef57164b05c42f037cd40f5d4357f4"
|
||||
dependencies = [
|
||||
"clipboard-win",
|
||||
"core-graphics",
|
||||
"image",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
"parking_lot",
|
||||
"windows-sys 0.48.0",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
@@ -1292,6 +1295,7 @@ dependencies = [
|
||||
"accesskit_winit",
|
||||
"ahash",
|
||||
"arboard",
|
||||
"bytemuck",
|
||||
"document-features",
|
||||
"egui",
|
||||
"log",
|
||||
@@ -2203,12 +2207,24 @@ dependencies = [
|
||||
"byteorder-lite",
|
||||
"color_quant",
|
||||
"gif",
|
||||
"image-webp",
|
||||
"num-traits",
|
||||
"png",
|
||||
"tiff",
|
||||
"zune-core",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image-webp"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e031e8e3d94711a9ccb5d6ea357439ef3dcbed361798bd4071dc4d9793fbe22f"
|
||||
dependencies = [
|
||||
"byteorder-lite",
|
||||
"quick-error",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "images"
|
||||
version = "0.1.0"
|
||||
@@ -2301,6 +2317,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jpeg-decoder"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f5d4a7da358eff58addd2877a45865158f0d78c911d43a5784ceb7bbf52833b0"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.72"
|
||||
@@ -3166,6 +3188,12 @@ dependencies = [
|
||||
"puffin_http",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.30.0"
|
||||
@@ -3866,6 +3894,17 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiff"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"jpeg-decoder",
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.36"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
] }
|
||||
|
||||
@@ -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")))]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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/");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
|
||||
81
crates/egui_demo_lib/src/demo/tests/clipboard_test.rs
Normal file
81
crates/egui_demo_lib/src/demo/tests/clipboard_test.rs
Normal 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!());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b8ca5a27491c0589a97e43a70bc10dc52778d25ca3f7e7c895dbbbb784adfcfa
|
||||
size 33245
|
||||
oid sha256:0a1099b85a1aaf20f3f1e091bc68259f811737feaefdfcc12acd067eca8f9117
|
||||
size 27083
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e640606207265b4f040f793b0ffb989504b6a98b89e95e77a9a9d3e3abc9327a
|
||||
size 80933
|
||||
oid sha256:6969c6da67ea6cc7ebbbd7a2cc1cb13d4720befe28126367cbf2b2679d037674
|
||||
size 82363
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d122b1a995e691b5049c57d65c9f222a5f1639b1e4f6f96f91823444339693cc
|
||||
size 160540
|
||||
oid sha256:b3dc1bf9a59007a6ad0fb66a345d6cf272bd8bdcd26b10dbf411c1280e62b6fc
|
||||
size 158285
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
186
crates/egui_extras/src/loaders/webp_loader.rs
Normal file
186
crates/egui_extras/src/loaders/webp_loader.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
3
crates/egui_kittest/tests/snapshots/image_snapshots.png
Normal file
3
crates/egui_kittest/tests/snapshots/image_snapshots.png
Normal file
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:31faeb4e5f488b8bcee5e090accd326d7e43b264e81768ae7c1907e3b6d0f739
|
||||
size 2121
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:12eb9463cda6c2b1a160f085324f1afdfc5ced9ff0857df117030d8771259e5e
|
||||
size 303453
|
||||
oid sha256:a836741d52e1972b2047cefaabf59f601637d430d4b41bf6407ebda4f7931dac
|
||||
size 273450
|
||||
|
||||
BIN
examples/images/src/cat.webp
Normal file
BIN
examples/images/src/cat.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 480 KiB |
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user