diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 471301af7..679023315 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -49,7 +49,7 @@ jobs: run: cargo check --locked --all-features --all-targets - name: check egui_extras --all-features - run: cargo check --locked --all-features --all-targets -p egui_extras + run: cargo check --locked --all-features -p egui_extras - name: check default features run: cargo check --locked --all-targets @@ -57,11 +57,14 @@ jobs: - name: check --no-default-features run: cargo check --locked --no-default-features --lib --all-targets - - name: check epaint --no-default-features - run: cargo check --locked --no-default-features --lib --all-targets -p epaint - - name: check eframe --no-default-features - run: cargo check --locked --no-default-features --features x11 --lib --all-targets -p eframe + run: cargo check --locked --no-default-features --features x11 --lib -p eframe + + - name: check egui_extras --no-default-features + run: cargo check --locked --no-default-features --lib -p egui_extras + + - name: check epaint --no-default-features + run: cargo check --locked --no-default-features --lib -p epaint - name: Test doc-tests run: cargo test --doc --all-features diff --git a/Cargo.lock b/Cargo.lock index 3b24d45d7..b2f275ce3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1083,16 +1083,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ea835d29036a4087793836fa931b08837ad5e957da9e23886b29586fb9b6650" -[[package]] -name = "download_image" -version = "0.1.0" -dependencies = [ - "eframe", - "egui_extras", - "env_logger", - "image", -] - [[package]] name = "dyn-clonable" version = "0.9.0" @@ -1232,6 +1222,7 @@ dependencies = [ "image", "log", "poll-promise", + "rfd", "serde", "wasm-bindgen", "wasm-bindgen-futures", @@ -1248,10 +1239,8 @@ dependencies = [ "egui", "egui_extras", "egui_plot", - "enum-map", "log", "serde", - "syntect", "unicode_names2", ] @@ -1263,11 +1252,14 @@ dependencies = [ "document-features", "egui", "ehttp", + "enum-map", "image", "log", + "mime_guess", "puffin", "resvg", "serde", + "syntect", "tiny-skia", "usvg", ] @@ -2027,6 +2019,16 @@ dependencies = [ "png", ] +[[package]] +name = "images" +version = "0.1.0" +dependencies = [ + "eframe", + "egui_extras", + "env_logger", + "image", +] + [[package]] name = "imagesize" version = "0.10.1" @@ -2311,6 +2313,22 @@ dependencies = [ "paste", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4192263c238a5f0d0c6bfd21f336a313a4ce1c450542449ca191bb657b4642ef" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -2999,16 +3017,6 @@ dependencies = [ "usvg", ] -[[package]] -name = "retained_image" -version = "0.1.0" -dependencies = [ - "eframe", - "egui_extras", - "env_logger", - "image", -] - [[package]] name = "rfd" version = "0.11.4" @@ -3409,15 +3417,6 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" -[[package]] -name = "svg" -version = "0.1.0" -dependencies = [ - "eframe", - "egui_extras", - "env_logger", -] - [[package]] name = "svgtypes" version = "0.8.2" @@ -3748,6 +3747,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "unicase" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d2d4dafb69621809a81864c9c1b864479e1235c0dd4e199924b9742439ed89" +dependencies = [ + "version_check", +] + [[package]] name = "unicode-bidi" version = "0.3.13" diff --git a/README.md b/README.md index ae9cf01fe..f210ba255 100644 --- a/README.md +++ b/README.md @@ -198,55 +198,7 @@ These are the official egui integrations: Missing an integration for the thing you're working on? Create one, it's easy! ### Writing your own egui integration - -You need to collect [`egui::RawInput`](https://docs.rs/egui/latest/egui/struct.RawInput.html) and handle [`egui::FullOutput`](https://docs.rs/egui/latest/egui/struct.FullOutput.html). The basic structure is this: - -``` rust -let mut egui_ctx = egui::CtxRef::default(); - -// Game loop: -loop { - // Gather input (mouse, touches, keyboard, screen size, etc): - let raw_input: egui::RawInput = my_integration.gather_input(); - let full_output = egui_ctx.run(raw_input, |egui_ctx| { - my_app.ui(egui_ctx); // add panels, windows and widgets to `egui_ctx` here - }); - let clipped_primitives = egui_ctx.tessellate(full_output.shapes); // creates triangles to paint - - my_integration.paint(&full_output.textures_delta, clipped_primitives); - - let platform_output = full_output.platform_output; - my_integration.set_cursor_icon(platform_output.cursor_icon); - if !platform_output.copied_text.is_empty() { - my_integration.set_clipboard_text(platform_output.copied_text); - } - // See `egui::FullOutput` and `egui::PlatformOutput` for more -} -``` - -For a reference OpenGL backend, see [the `egui_glium` painter](https://github.com/emilk/egui/blob/master/crates/egui_glium/src/painter.rs) or [the `egui_glow` painter](https://github.com/emilk/egui/blob/master/crates/egui_glow/src/painter.rs). - -### Debugging your integration - -#### Things look jagged - -* Turn off backface culling. - -#### My text is blurry - -* Make sure you set the proper `pixels_per_point` in the input to egui. -* Make sure the texture sampler is not off by half a pixel. Try nearest-neighbor sampler to check. - -#### My windows are too transparent or too dark - -* egui uses premultiplied alpha, so make sure your blending function is `(ONE, ONE_MINUS_SRC_ALPHA)`. -* Make sure your texture sampler is clamped (`GL_CLAMP_TO_EDGE`). -* egui prefers linear color spaces for all blending so: - * Use an sRGBA-aware texture if available (e.g. `GL_SRGB8_ALPHA8`). - * Otherwise: remember to decode gamma in the fragment shader. - * Decode the gamma of the incoming vertex colors in your vertex shader. - * Turn on sRGBA/linear framebuffer if available (`GL_FRAMEBUFFER_SRGB`). - * Otherwise: gamma-encode the colors before you write them again. +See . ## Why immediate mode diff --git a/crates/eframe/README.md b/crates/eframe/README.md index 55993943d..2356af0e0 100644 --- a/crates/eframe/README.md +++ b/crates/eframe/README.md @@ -60,4 +60,4 @@ Not all rust crates work when compiled to WASM, but here are some useful crates ## Name -The _frame_ in `eframe` stands both for the frame in which your `egui` app resides and also for "framework" (`frame` is a framework, `egui` is a library). +The _frame_ in `eframe` stands both for the frame in which your `egui` app resides and also for "framework" (`eframe` is a framework, `egui` is a library). diff --git a/crates/eframe/src/epi/mod.rs b/crates/eframe/src/epi/mod.rs index 597a9122c..d45b39cfb 100644 --- a/crates/eframe/src/epi/mod.rs +++ b/crates/eframe/src/epi/mod.rs @@ -1158,6 +1158,7 @@ impl Storage for DummyStorage { /// Get and deserialize the [RON](https://github.com/ron-rs/ron) stored at the given key. #[cfg(feature = "ron")] pub fn get_value(storage: &dyn Storage, key: &str) -> Option { + crate::profile_function!(key); storage .get_string(key) .and_then(|value| match ron::from_str(&value) { @@ -1172,6 +1173,7 @@ pub fn get_value(storage: &dyn Storage, key: &st /// Serialize the given value as [RON](https://github.com/ron-rs/ron) and store with the given key. #[cfg(feature = "ron")] pub fn set_value(storage: &mut dyn Storage, key: &str, value: &T) { + crate::profile_function!(key); match ron::ser::to_string(value) { Ok(string) => storage.set_string(key, string), Err(err) => log::error!("eframe failed to encode data using ron: {}", err), diff --git a/crates/eframe/src/lib.rs b/crates/eframe/src/lib.rs index 2972d6727..4ceae307a 100644 --- a/crates/eframe/src/lib.rs +++ b/crates/eframe/src/lib.rs @@ -324,7 +324,6 @@ pub type Result = std::result::Result; // --------------------------------------------------------------------------- -#[cfg(not(target_arch = "wasm32"))] mod profiling_scopes { #![allow(unused_macros)] #![allow(unused_imports)] @@ -333,6 +332,7 @@ mod profiling_scopes { macro_rules! profile_function { ($($arg: tt)*) => { #[cfg(feature = "puffin")] + #[cfg(not(target_arch = "wasm32"))] // Disabled on web because of the coarse 1ms clock resolution there. puffin::profile_function!($($arg)*); }; } @@ -342,11 +342,12 @@ mod profiling_scopes { macro_rules! profile_scope { ($($arg: tt)*) => { #[cfg(feature = "puffin")] + #[cfg(not(target_arch = "wasm32"))] // Disabled on web because of the coarse 1ms clock resolution there. puffin::profile_scope!($($arg)*); }; } pub(crate) use profile_scope; } -#[cfg(not(target_arch = "wasm32"))] +#[allow(unused_imports)] pub(crate) use profiling_scopes::*; diff --git a/crates/eframe/src/native/app_icon.rs b/crates/eframe/src/native/app_icon.rs index 20a49a6e2..61713055c 100644 --- a/crates/eframe/src/native/app_icon.rs +++ b/crates/eframe/src/native/app_icon.rs @@ -191,6 +191,8 @@ fn set_app_icon_windows(icon_data: &IconData) -> AppIconStatus { #[cfg(target_os = "macos")] #[allow(unsafe_code)] fn set_title_and_icon_mac(title: &str, icon_data: Option<&IconData>) -> AppIconStatus { + crate::profile_function!(); + use cocoa::{ appkit::{NSApp, NSApplication, NSImage, NSMenu, NSWindow}, base::{id, nil}, @@ -221,12 +223,15 @@ fn set_title_and_icon_mac(title: &str, icon_data: Option<&IconData>) -> AppIconS png_bytes.len() as u64, ); let app_icon = NSImage::initWithData_(NSImage::alloc(nil), data); + + crate::profile_scope!("setApplicationIconImage_"); app.setApplicationIconImage_(app_icon); } // Change the title in the top bar - for python processes this would be again "python" otherwise. let main_menu = app.mainMenu(); let app_menu: id = msg_send![main_menu.itemAtIndex_(0), submenu]; + crate::profile_scope!("setTitle_"); app_menu.setTitle_(NSString::alloc(nil).init_str(title)); // The title in the Dock apparently can't be changed. diff --git a/crates/eframe/src/native/epi_integration.rs b/crates/eframe/src/native/epi_integration.rs index 62055c728..d928767a0 100644 --- a/crates/eframe/src/native/epi_integration.rs +++ b/crates/eframe/src/native/epi_integration.rs @@ -178,6 +178,7 @@ pub fn apply_native_options_to_window( native_options: &crate::NativeOptions, window_settings: Option, ) { + crate::profile_function!(); use winit::window::WindowLevel; window.set_window_level(if native_options.always_on_top { WindowLevel::AlwaysOnTop @@ -443,6 +444,8 @@ impl EpiIntegration { egui_winit: &mut egui_winit::State, viewport_id: ViewportId, ) -> EventResponse { + crate::profile_function!(); + use winit::event::{ElementState, MouseButton, WindowEvent}; match event { @@ -614,6 +617,7 @@ const STORAGE_EGUI_MEMORY_KEY: &str = "egui"; const STORAGE_WINDOW_KEY: &str = "window"; pub fn load_window_settings(_storage: Option<&dyn epi::Storage>) -> Option { + crate::profile_function!(); #[cfg(feature = "persistence")] { epi::get_value(_storage?, STORAGE_WINDOW_KEY) @@ -623,6 +627,7 @@ pub fn load_window_settings(_storage: Option<&dyn epi::Storage>) -> Option) -> Option { + crate::profile_function!(); #[cfg(feature = "persistence")] { epi::get_value(_storage?, STORAGE_EGUI_MEMORY_KEY) diff --git a/crates/eframe/src/native/file_storage.rs b/crates/eframe/src/native/file_storage.rs index eae742cff..6b059e817 100644 --- a/crates/eframe/src/native/file_storage.rs +++ b/crates/eframe/src/native/file_storage.rs @@ -1,5 +1,6 @@ use std::{ collections::HashMap, + io::Write, path::{Path, PathBuf}, }; @@ -31,6 +32,7 @@ pub struct FileStorage { impl Drop for FileStorage { fn drop(&mut self) { if let Some(join_handle) = self.last_save_join_handle.take() { + crate::profile_scope!("wait_for_save"); join_handle.join().ok(); } } @@ -39,6 +41,7 @@ impl Drop for FileStorage { impl FileStorage { /// Store the state in this .ron file. fn from_ron_filepath(ron_filepath: impl Into) -> Self { + crate::profile_function!(); let ron_filepath: PathBuf = ron_filepath.into(); log::debug!("Loading app state from {:?}…", ron_filepath); Self { @@ -51,6 +54,7 @@ impl FileStorage { /// Find a good place to put the files that the OS likes. pub fn from_app_id(app_id: &str) -> Option { + crate::profile_function!(app_id); if let Some(data_dir) = storage_dir(app_id) { if let Err(err) = std::fs::create_dir_all(&data_dir) { log::warn!( @@ -83,6 +87,7 @@ impl crate::Storage for FileStorage { fn flush(&mut self) { if self.dirty { + crate::profile_function!(); self.dirty = false; let file_path = self.ron_filepath.clone(); @@ -122,10 +127,13 @@ fn save_to_disk(file_path: &PathBuf, kv: &HashMap) { match std::fs::File::create(file_path) { Ok(file) => { + let mut writer = std::io::BufWriter::new(file); let config = Default::default(); - if let Err(err) = ron::ser::to_writer_pretty(file, &kv, config) { - log::warn!("Failed to serialize app state: {err}"); + if let Err(err) = ron::ser::to_writer_pretty(&mut writer, &kv, config) + .and_then(|_| writer.flush().map_err(|err| err.into())) + { + log::warn!("Failed to serialize app state: {}", err); } else { log::trace!("Persisted to {:?}", file_path); } @@ -142,6 +150,7 @@ fn read_ron(ron_path: impl AsRef) -> Option where T: serde::de::DeserializeOwned, { + crate::profile_function!(); match std::fs::File::open(ron_path) { Ok(file) => { let reader = std::io::BufReader::new(file); diff --git a/crates/eframe/src/native/run.rs b/crates/eframe/src/native/run.rs index 361c08bb5..d86d94410 100644 --- a/crates/eframe/src/native/run.rs +++ b/crates/eframe/src/native/run.rs @@ -120,6 +120,7 @@ trait WinitApp { fn create_event_loop_builder( native_options: &mut epi::NativeOptions, ) -> EventLoopBuilder { + crate::profile_function!(); let mut event_loop_builder = winit::event_loop::EventLoopBuilder::with_user_event(); if let Some(hook) = std::mem::take(&mut native_options.event_loop_builder) { @@ -129,6 +130,14 @@ fn create_event_loop_builder( event_loop_builder } +fn create_event_loop(native_options: &mut epi::NativeOptions) -> EventLoop { + crate::profile_function!(); + let mut builder = create_event_loop_builder(native_options); + + crate::profile_scope!("EventLoopBuilder::build"); + builder.build() +} + /// Access a thread-local event loop. /// /// We reuse the event-loop so we can support closing and opening an eframe window @@ -145,8 +154,7 @@ fn with_event_loop( // do that as part of the lazy thread local storage initialization and so we instead // create the event loop lazily here let mut event_loop = event_loop.borrow_mut(); - let event_loop = event_loop - .get_or_insert_with(|| create_event_loop_builder(&mut native_options).build()); + let event_loop = event_loop.get_or_insert_with(|| create_event_loop(&mut native_options)); f(event_loop, native_options) }) } @@ -165,6 +173,8 @@ fn run_and_return( let mut returned_result = Ok(()); event_loop.run_return(|event, event_loop, control_flow| { + crate::profile_scope!("winit_event", short_event_description(&event)); + WINIT_EVENT_LOOP.with(|row_event_loop| *row_event_loop.write() = event_loop); let events = match &event { @@ -324,6 +334,8 @@ fn run_and_exit(event_loop: EventLoop, mut winit_app: impl WinitApp + let mut windows_next_repaint_times = HashMap::default(); event_loop.run(move |event, event_loop, control_flow| { + crate::profile_scope!("winit_event", short_event_description(&event)); + WINIT_EVENT_LOOP.with(|row_event_loop| *row_event_loop.write() = event_loop); let events = match event { @@ -536,6 +548,8 @@ mod glow_integration { native_options: &epi::NativeOptions, event_loop: &EventLoopWindowTarget, ) -> Result { + crate::profile_function!(); + use glutin::prelude::*; // convert native options to glutin options let hardware_acceleration = match native_options.hardware_acceleration { @@ -576,26 +590,35 @@ mod glow_integration { "trying to create glutin Display with config: {:?}", &config_template_builder ); - // create gl display. this may probably create a window too on most platforms. definitely on `MS windows`. never on android. - let (window, gl_config) = glutin_winit::DisplayBuilder::new() + + // Create GL display. This may probably create a window too on most platforms. Definitely on `MS windows`. Never on Android. + let display_builder = glutin_winit::DisplayBuilder::new() // we might want to expose this option to users in the future. maybe using an env var or using native_options. .with_preference(glutin_winit::ApiPrefence::FallbackEgl) // https://github.com/emilk/egui/issues/2520#issuecomment-1367841150 - .with_window_builder(Some(create_winit_window_builder(&window_builder))) - .build( - event_loop, - config_template_builder.clone(), - |mut config_iterator| { - let config = config_iterator.next().expect( + .with_window_builder(Some(create_winit_window_builder(&window_builder))); + + let (window, gl_config) = { + crate::profile_scope!("DisplayBuilder::build"); + + display_builder + .build( + event_loop, + config_template_builder.clone(), + |mut config_iterator| { + let config = config_iterator.next().expect( "failed to find a matching configuration for creating glutin config", ); - log::debug!( - "using the first config from config picker closure. config: {:?}", - &config - ); - config - }, - ) - .map_err(|e| crate::Error::NoGlutinConfigs(config_template_builder.build(), e))?; + log::debug!( + "using the first config from config picker closure. config: {:?}", + &config + ); + config + }, + ) + .map_err(|e| { + crate::Error::NoGlutinConfigs(config_template_builder.build(), e) + })? + }; let gl_display = gl_config.display(); log::debug!( @@ -615,10 +638,15 @@ mod glow_integration { let fallback_context_attributes = glutin::context::ContextAttributesBuilder::new() .with_context_api(glutin::context::ContextApi::Gles(None)) .build(raw_window_handle); - let gl_context = match gl_config - .display() - .create_context(&gl_config, &context_attributes) - { + + let gl_context_result = { + crate::profile_scope!("create_context"); + gl_config + .display() + .create_context(&gl_config, &context_attributes) + }; + + let gl_context = match gl_context_result { Ok(it) => it, Err(err) => { log::warn!("failed to create context using default context attributes {context_attributes:?} due to error: {err}"); @@ -675,6 +703,8 @@ mod glow_integration { /// /// we presently assume that we will fn on_resume(&mut self, event_loop: &EventLoopWindowTarget) -> Result<()> { + crate::profile_function!(); + let values = self .windows .values() @@ -849,6 +879,7 @@ mod glow_integration { native_options: epi::NativeOptions, app_creator: epi::AppCreator, ) -> Self { + crate::profile_function!(); Self { repaint_proxy: Arc::new(egui::mutex::Mutex::new(event_loop.create_proxy())), app_name: app_name.to_owned(), @@ -889,6 +920,7 @@ mod glow_integration { } let gl = unsafe { + crate::profile_scope!("glow::Context::from_loader_function"); glow::Context::from_loader_function(|s| { let s = std::ffi::CString::new(s) .expect("failed to construct C string from string for gl proc address"); @@ -901,6 +933,7 @@ mod glow_integration { } fn init_run_state(&mut self, event_loop: &EventLoopWindowTarget) -> Result<()> { + crate::profile_function!(); let storage = epi_integration::create_storage( self.native_options .app_id @@ -960,14 +993,6 @@ mod glow_integration { let theme = system_theme.unwrap_or(self.native_options.default_theme); integration.egui_ctx.set_visuals(theme.egui_visuals()); - gl_window - .window(ViewportId::MAIN) - .read() - .window - .as_ref() - .unwrap() - .read() - .set_ime_allowed(true); if self.native_options.mouse_passthrough { gl_window .window(ViewportId::MAIN) @@ -1286,7 +1311,10 @@ mod glow_integration { } fn save_and_destroy(&mut self) { + crate::profile_function!(); if let Some(running) = self.running.write().take() { + crate::profile_function!(); + running.integration.write().save( running.app.write().as_mut(), running @@ -1543,6 +1571,8 @@ mod glow_integration { event_loop: &EventLoopWindowTarget, event: &winit::event::Event<'_, UserEvent>, ) -> Result { + crate::profile_function!(); + Ok(match event { winit::event::Event::Resumed => { // first resume event. @@ -1632,7 +1662,7 @@ mod glow_integration { // See: https://github.com/rust-windowing/winit/issues/208 // This solves an issue where the app would panic when minimizing on Windows. let glutin_ctx = &mut *running.glutin_ctx.write(); - if physical_size.width > 0 && physical_size.height > 0 { + if 0 < physical_size.width && 0 < physical_size.height { if let Some(id) = glutin_ctx.window_maps.get(window_id) { glutin_ctx.resize(*id, *physical_size); } @@ -1723,11 +1753,14 @@ mod glow_integration { EventResult::Wait } } + #[cfg(feature = "accesskit")] winit::event::Event::UserEvent(UserEvent::AccessKitActionRequest( accesskit_winit::ActionRequestEvent { request, window_id }, )) => { if let Some(running) = self.running.read().as_ref() { + crate::profile_scope!("on_accesskit_action_request"); + let glutin_ctx = running.glutin_ctx.read(); if let Some(viewport_id) = glutin_ctx.window_maps.get(window_id).copied() { if let Some(viewport) = glutin_ctx.windows.get(&viewport_id).cloned() { @@ -1764,14 +1797,14 @@ mod glow_integration { run_and_return(event_loop, glow_eframe) }) } else { - let event_loop = create_event_loop_builder(&mut native_options).build(); + let event_loop = create_event_loop(&mut native_options); let glow_eframe = GlowWinitApp::new(&event_loop, app_name, native_options, app_creator); run_and_exit(event_loop, glow_eframe); } #[cfg(target_os = "ios")] { - let event_loop = create_event_loop_builder(&mut native_options).build(); + let event_loop = create_event_loop(&mut native_options); let glow_eframe = GlowWinitApp::new(&event_loop, app_name, native_options, app_creator); run_and_exit(event_loop, glow_eframe); } @@ -1847,6 +1880,7 @@ mod wgpu_integration { native_options: epi::NativeOptions, app_creator: epi::AppCreator, ) -> Self { + crate::profile_function!(); #[cfg(feature = "__screenshot")] assert!( std::env::var("EFRAME_SCREENSHOT_TO").is_err(), @@ -1870,10 +1904,15 @@ mod wgpu_integration { native_options: &NativeOptions, ) -> std::result::Result<(winit::window::Window, ViewportBuilder), winit::error::OsError> { + crate::profile_function!(); + let window_settings = epi_integration::load_window_settings(storage); let window_builder = epi_integration::window_builder(event_loop, title, native_options, window_settings); - let window = create_winit_window_builder(&window_builder).build(event_loop)?; + let window = { + crate::profile_scope!("WindowBuilder::build"); + create_winit_window_builder(&window_builder).build(event_loop)? + }; epi_integration::apply_native_options_to_window( &window, native_options, @@ -1935,6 +1974,7 @@ mod wgpu_integration { fn set_window(&mut self, id: ViewportId) -> std::result::Result<(), egui_wgpu::WgpuError> { if let Some(running) = &mut self.running { + crate::profile_function!(); if let Some(Window { window, .. }) = running.windows.read().get(&id) { let window = window.clone(); if let Some(win) = &window { @@ -1966,6 +2006,8 @@ mod wgpu_integration { window: winit::window::Window, builder: ViewportBuilder, ) -> std::result::Result<(), egui_wgpu::WgpuError> { + crate::profile_function!(); + #[allow(unsafe_code, unused_mut, unused_unsafe)] let mut painter = egui_wgpu::winit::Painter::new( self.native_options.wgpu_options.clone(), @@ -2001,8 +2043,6 @@ mod wgpu_integration { let theme = system_theme.unwrap_or(self.native_options.default_theme); integration.egui_ctx.set_visuals(theme.egui_visuals()); - window.set_ime_allowed(true); - { let event_loop_proxy = self.repaint_proxy.clone(); @@ -2026,7 +2066,7 @@ mod wgpu_integration { let app_creator = std::mem::take(&mut self.app_creator) .expect("Single-use AppCreator has unexpectedly already been taken"); - let mut app = app_creator(&epi::CreationContext { + let cc = epi::CreationContext { egui_ctx: integration.egui_ctx.clone(), integration_info: integration.frame.info().clone(), storage: integration.frame.storage(), @@ -2035,7 +2075,11 @@ mod wgpu_integration { wgpu_render_state, raw_display_handle: window.raw_display_handle(), raw_window_handle: window.raw_window_handle(), - }); + }; + let mut app = { + crate::profile_scope!("user_app_creator"); + app_creator(&cc) + }; if app.warm_up_enabled() { integration.warm_up(app.as_mut(), &window, &mut state); @@ -2189,6 +2233,7 @@ mod wgpu_integration { fn save_and_destroy(&mut self) { if let Some(mut running) = self.running.take() { + crate::profile_function!(); if let Some(Window { window, .. }) = running.windows.read().get(&ViewportId::MAIN) { running .integration @@ -2403,7 +2448,9 @@ mod wgpu_integration { event_loop: &EventLoopWindowTarget, event: &winit::event::Event<'_, UserEvent>, ) -> Result { + crate::profile_function!(); self.build_windows(event_loop); + Ok(match event { winit::event::Event::Resumed => { if let Some(running) = &self.running { @@ -2484,7 +2531,7 @@ mod wgpu_integration { if let Some(viewport_id) = running.windows_id.read().get(window_id).copied() { - if physical_size.width > 0 && physical_size.height > 0 { + if 0 < physical_size.width && 0 < physical_size.height { running.painter.write().on_window_resized( viewport_id, physical_size.width, @@ -2600,14 +2647,14 @@ mod wgpu_integration { run_and_return(event_loop, wgpu_eframe) }) } else { - let event_loop = create_event_loop_builder(&mut native_options).build(); + let event_loop = create_event_loop(&mut native_options); let wgpu_eframe = WgpuWinitApp::new(&event_loop, app_name, native_options, app_creator); run_and_exit(event_loop, wgpu_eframe); } #[cfg(target_os = "ios")] { - let event_loop = create_event_loop_builder(&mut native_options).build(); + let event_loop = create_event_loop(&mut native_options); let wgpu_eframe = WgpuWinitApp::new(&event_loop, app_name, native_options, app_creator); run_and_exit(event_loop, wgpu_eframe); } @@ -2628,3 +2675,67 @@ fn system_theme(window: &winit::window::Window, options: &NativeOptions) -> Opti None } } + +// For the puffin profiler! +#[allow(dead_code)] // Only used for profiling +fn short_event_description(event: &winit::event::Event<'_, UserEvent>) -> &'static str { + use winit::event::{DeviceEvent, Event, StartCause, WindowEvent}; + + match event { + Event::Suspended => "Event::Suspended", + Event::Resumed => "Event::Resumed", + Event::MainEventsCleared => "Event::MainEventsCleared", + Event::RedrawRequested(_) => "Event::RedrawRequested", + Event::RedrawEventsCleared => "Event::RedrawEventsCleared", + Event::LoopDestroyed => "Event::LoopDestroyed", + Event::UserEvent(user_event) => match user_event { + UserEvent::RequestRepaint { .. } => "UserEvent::RequestRepaint", + #[cfg(feature = "accesskit")] + UserEvent::AccessKitActionRequest(_) => "UserEvent::AccessKitActionRequest", + }, + Event::DeviceEvent { event, .. } => match event { + DeviceEvent::Added { .. } => "DeviceEvent::Added", + DeviceEvent::Removed { .. } => "DeviceEvent::Removed", + DeviceEvent::MouseMotion { .. } => "DeviceEvent::MouseMotion", + DeviceEvent::MouseWheel { .. } => "DeviceEvent::MouseWheel", + DeviceEvent::Motion { .. } => "DeviceEvent::Motion", + DeviceEvent::Button { .. } => "DeviceEvent::Button", + DeviceEvent::Key { .. } => "DeviceEvent::Key", + DeviceEvent::Text { .. } => "DeviceEvent::Text", + }, + Event::NewEvents(start_cause) => match start_cause { + StartCause::ResumeTimeReached { .. } => "NewEvents::ResumeTimeReached", + StartCause::WaitCancelled { .. } => "NewEvents::WaitCancelled", + StartCause::Poll => "NewEvents::Poll", + StartCause::Init => "NewEvents::Init", + }, + Event::WindowEvent { event, .. } => match event { + WindowEvent::Resized { .. } => "WindowEvent::Resized", + WindowEvent::Moved { .. } => "WindowEvent::Moved", + WindowEvent::CloseRequested { .. } => "WindowEvent::CloseRequested", + WindowEvent::Destroyed { .. } => "WindowEvent::Destroyed", + WindowEvent::DroppedFile { .. } => "WindowEvent::DroppedFile", + WindowEvent::HoveredFile { .. } => "WindowEvent::HoveredFile", + WindowEvent::HoveredFileCancelled { .. } => "WindowEvent::HoveredFileCancelled", + WindowEvent::ReceivedCharacter { .. } => "WindowEvent::ReceivedCharacter", + WindowEvent::Focused { .. } => "WindowEvent::Focused", + WindowEvent::KeyboardInput { .. } => "WindowEvent::KeyboardInput", + WindowEvent::ModifiersChanged { .. } => "WindowEvent::ModifiersChanged", + WindowEvent::Ime { .. } => "WindowEvent::Ime", + WindowEvent::CursorMoved { .. } => "WindowEvent::CursorMoved", + WindowEvent::CursorEntered { .. } => "WindowEvent::CursorEntered", + WindowEvent::CursorLeft { .. } => "WindowEvent::CursorLeft", + WindowEvent::MouseWheel { .. } => "WindowEvent::MouseWheel", + WindowEvent::MouseInput { .. } => "WindowEvent::MouseInput", + WindowEvent::TouchpadMagnify { .. } => "WindowEvent::TouchpadMagnify", + WindowEvent::SmartMagnify { .. } => "WindowEvent::SmartMagnify", + WindowEvent::TouchpadRotate { .. } => "WindowEvent::TouchpadRotate", + WindowEvent::TouchpadPressure { .. } => "WindowEvent::TouchpadPressure", + WindowEvent::AxisMotion { .. } => "WindowEvent::AxisMotion", + WindowEvent::Touch { .. } => "WindowEvent::Touch", + WindowEvent::ScaleFactorChanged { .. } => "WindowEvent::ScaleFactorChanged", + WindowEvent::ThemeChanged { .. } => "WindowEvent::ThemeChanged", + WindowEvent::Occluded { .. } => "WindowEvent::Occluded", + }, + } +} diff --git a/crates/eframe/src/web/text_agent.rs b/crates/eframe/src/web/text_agent.rs index 1688163b3..d4f3b5ca3 100644 --- a/crates/eframe/src/web/text_agent.rs +++ b/crates/eframe/src/web/text_agent.rs @@ -101,14 +101,13 @@ pub fn install_text_agent(runner_ref: &WebRunner) -> Result<(), JsValue> { // When input lost focus, focus on it again. // It is useful when user click somewhere outside canvas. + let input_refocus = input.clone(); runner_ref.add_event_listener(&input, "focusout", move |_event: web_sys::MouseEvent, _| { // Delay 10 ms, and focus again. - let func = js_sys::Function::new_no_args(&format!( - "document.getElementById('{AGENT_ID}').focus()" - )); - window - .set_timeout_with_callback_and_timeout_and_arguments_0(&func, 10) - .unwrap(); + let input_refocus = input_refocus.clone(); + call_after_delay(std::time::Duration::from_millis(10), move || { + input_refocus.focus().ok(); + }); })?; body.append_child(&input)?; diff --git a/crates/egui-wgpu/src/lib.rs b/crates/egui-wgpu/src/lib.rs index 7d0a3506c..8d9d5912c 100644 --- a/crates/egui-wgpu/src/lib.rs +++ b/crates/egui-wgpu/src/lib.rs @@ -203,22 +203,30 @@ pub fn depth_format_from_bits(depth_buffer: u8, stencil_buffer: u8) -> Option { - #[cfg(feature = "puffin")] - #[cfg(not(target_arch = "wasm32"))] - puffin::profile_function!($($arg)*); - }; -} -pub(crate) use profile_function; +mod profiling_scopes { + #![allow(unused_macros)] + #![allow(unused_imports)] -/// Profiling macro for feature "puffin" -macro_rules! profile_scope { - ($($arg: tt)*) => { - #[cfg(feature = "puffin")] - #[cfg(not(target_arch = "wasm32"))] - puffin::profile_scope!($($arg)*); - }; + /// Profiling macro for feature "puffin" + macro_rules! profile_function { + ($($arg: tt)*) => { + #[cfg(feature = "puffin")] + #[cfg(not(target_arch = "wasm32"))] // Disabled on web because of the coarse 1ms clock resolution there. + puffin::profile_function!($($arg)*); + }; + } + pub(crate) use profile_function; + + /// Profiling macro for feature "puffin" + macro_rules! profile_scope { + ($($arg: tt)*) => { + #[cfg(feature = "puffin")] + #[cfg(not(target_arch = "wasm32"))] // Disabled on web because of the coarse 1ms clock resolution there. + puffin::profile_scope!($($arg)*); + }; + } + pub(crate) use profile_scope; } -pub(crate) use profile_scope; + +#[allow(unused_imports)] +pub(crate) use profiling_scopes::*; diff --git a/crates/egui-wgpu/src/renderer.rs b/crates/egui-wgpu/src/renderer.rs index c97fba882..62d969833 100644 --- a/crates/egui-wgpu/src/renderer.rs +++ b/crates/egui-wgpu/src/renderer.rs @@ -445,6 +445,13 @@ impl Renderer { needs_reset = true; + let info = PaintCallbackInfo { + viewport: callback.rect, + clip_rect: *clip_rect, + pixels_per_point, + screen_size_px: size_in_pixels, + }; + { // We're setting a default viewport for the render pass as a // courtesy for the user, so that they don't have to think about @@ -455,29 +462,19 @@ impl Renderer { // viewport during the paint callback, effectively overriding this // one. - let min = (callback.rect.min.to_vec2() * pixels_per_point).round(); - let max = (callback.rect.max.to_vec2() * pixels_per_point).round(); + let viewport_px = info.viewport_in_pixels(); render_pass.set_viewport( - min.x, - min.y, - max.x - min.x, - max.y - min.y, + viewport_px.left_px, + viewport_px.top_px, + viewport_px.width_px, + viewport_px.height_px, 0.0, 1.0, ); } - cbfn.0.paint( - PaintCallbackInfo { - viewport: callback.rect, - clip_rect: *clip_rect, - pixels_per_point, - screen_size_px: size_in_pixels, - }, - render_pass, - &self.callback_resources, - ); + cbfn.0.paint(info, render_pass, &self.callback_resources); } } } @@ -605,9 +602,8 @@ impl Renderer { /// Get the WGPU texture and bind group associated to a texture that has been allocated by egui. /// - /// This could be used by custom paint hooks to render images that have been added through with - /// [`egui_extras::RetainedImage`](https://docs.rs/egui_extras/latest/egui_extras/image/struct.RetainedImage.html) - /// or [`epaint::Context::load_texture`](https://docs.rs/egui/latest/egui/struct.Context.html#method.load_texture). + /// This could be used by custom paint hooks to render images that have been added through + /// [`epaint::Context::load_texture`](https://docs.rs/egui/latest/egui/struct.Context.html#method.load_texture). pub fn texture( &self, id: &epaint::TextureId, diff --git a/crates/egui-wgpu/src/winit.rs b/crates/egui-wgpu/src/winit.rs index 20423a147..be5bc5d33 100644 --- a/crates/egui-wgpu/src/winit.rs +++ b/crates/egui-wgpu/src/winit.rs @@ -143,6 +143,7 @@ impl Painter { render_state: &RenderState, present_mode: wgpu::PresentMode, ) { + crate::profile_function!(); let usage = if surface_state.supports_screenshot { wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_DST } else { @@ -188,6 +189,8 @@ impl Painter { viewport_id: ViewportId, window: Option<&winit::window::Window>, ) -> Result<(), crate::WgpuError> { + crate::profile_function!(); + if let Some(window) = window { let size = window.inner_size(); if self.surfaces.get(&viewport_id).is_none() { @@ -269,6 +272,7 @@ impl Painter { width_in_pixels: u32, height_in_pixels: u32, ) { + crate::profile_function!(); let render_state = self.render_state.as_ref().unwrap(); let surface_state = self.surfaces.get_mut(&viewport_id).unwrap(); @@ -329,6 +333,8 @@ impl Painter { width_in_pixels: u32, height_in_pixels: u32, ) { + crate::profile_function!(); + if self.surfaces.get(&viewport_id).is_some() { self.resize_and_generate_depth_texture_view_and_msaa_view( viewport_id, diff --git a/crates/egui-winit/src/lib.rs b/crates/egui-winit/src/lib.rs index 8c7907c97..d64c9b260 100644 --- a/crates/egui-winit/src/lib.rs +++ b/crates/egui-winit/src/lib.rs @@ -83,6 +83,8 @@ pub struct State { #[cfg(feature = "accesskit")] accesskit: Option, + + allow_ime: bool, } impl State { @@ -110,6 +112,8 @@ impl State { #[cfg(feature = "accesskit")] accesskit: None, + + allow_ime: false, } } @@ -227,6 +231,8 @@ impl State { egui_ctx: &egui::Context, event: &winit::event::WindowEvent<'_>, ) -> EventResponse { + crate::profile_function!(); + use winit::event::WindowEvent; match event { WindowEvent::ScaleFactorChanged { scale_factor, .. } => { @@ -706,6 +712,12 @@ impl State { self.clipboard.set(copied_text); } + let allow_ime = text_cursor_pos.is_some(); + if self.allow_ime != allow_ime { + self.allow_ime = allow_ime; + window.set_ime_allowed(allow_ime); + } + if let Some(egui::Pos2 { x, y }) = text_cursor_pos { window.set_ime_position(winit::dpi::LogicalPosition { x, y }); } @@ -1343,29 +1355,33 @@ pub fn changes_between_builders( } // --------------------------------------------------------------------------- -/// Profiling macro for feature "puffin" -#[allow(unused_macros)] -macro_rules! profile_function { - ($($arg: tt)*) => { - #[cfg(feature = "puffin")] - puffin::profile_function!($($arg)*); - }; +mod profiling_scopes { + #![allow(unused_macros)] + #![allow(unused_imports)] + + /// Profiling macro for feature "puffin" + macro_rules! profile_function { + ($($arg: tt)*) => { + #[cfg(feature = "puffin")] + #[cfg(not(target_arch = "wasm32"))] // Disabled on web because of the coarse 1ms clock resolution there. + puffin::profile_function!($($arg)*); + }; + } + pub(crate) use profile_function; + + /// Profiling macro for feature "puffin" + macro_rules! profile_scope { + ($($arg: tt)*) => { + #[cfg(feature = "puffin")] + #[cfg(not(target_arch = "wasm32"))] // Disabled on web because of the coarse 1ms clock resolution there. + puffin::profile_scope!($($arg)*); + }; + } + pub(crate) use profile_scope; } #[allow(unused_imports)] -pub(crate) use profile_function; - -/// Profiling macro for feature "puffin" -#[allow(unused_macros)] -macro_rules! profile_scope { - ($($arg: tt)*) => { - #[cfg(feature = "puffin")] - puffin::profile_scope!($($arg)*); - }; -} - -#[allow(unused_imports)] -pub(crate) use profile_scope; +pub(crate) use profiling_scopes::*; use winit::{ dpi::{LogicalPosition, LogicalSize}, window::{CursorGrabMode, WindowButtons, WindowLevel}, diff --git a/crates/egui/assets/ferris.png b/crates/egui/assets/ferris.png new file mode 100644 index 000000000..8741baa19 Binary files /dev/null and b/crates/egui/assets/ferris.png differ diff --git a/crates/egui/src/context.rs b/crates/egui/src/context.rs index 86e75a7bc..8e4573a5e 100644 --- a/crates/egui/src/context.rs +++ b/crates/egui/src/context.rs @@ -1,10 +1,13 @@ #![warn(missing_docs)] // Let's keep `Context` well-documented. +use std::borrow::Cow; use std::sync::Arc; +use crate::load::Bytes; +use crate::load::SizedTexture; use crate::{ animation_manager::AnimationManager, data::output::PlatformOutput, frame_state::FrameState, - input_state::*, layers::GraphicLayers, memory::Options, os::OperatingSystem, + input_state::*, layers::GraphicLayers, load::Loaders, memory::Options, os::OperatingSystem, output::FullOutput, util::IdTypeMap, TextureHandle, ViewportCommand, *, }; use ahash::HashMap; @@ -228,7 +231,7 @@ struct ContextImpl { #[cfg(feature = "accesskit")] accesskit_node_classes: accesskit::NodeClassSet, - loaders: load::Loaders, + loaders: Arc, } impl ContextImpl { @@ -1293,17 +1296,24 @@ impl Context { self.options(|opt| opt.style.clone()) } - /// The [`Style`] used by all new windows, panels etc. - /// - /// You can also use [`Ui::style_mut`] to change the style of a single [`Ui`]. + /// Mutate the [`Style`] used by all subsequent windows, panels etc. /// /// Example: /// ``` /// # let mut ctx = egui::Context::default(); - /// let mut style: egui::Style = (*ctx.style()).clone(); - /// style.spacing.item_spacing = egui::vec2(10.0, 20.0); - /// ctx.set_style(style); + /// ctx.style_mut(|style| { + /// style.spacing.item_spacing = egui::vec2(10.0, 20.0); + /// }); /// ``` + pub fn style_mut(&self, mutate_style: impl FnOnce(&mut Style)) { + self.options_mut(|opt| mutate_style(std::sync::Arc::make_mut(&mut opt.style))); + } + + /// The [`Style`] used by all new windows, panels etc. + /// + /// You can also change this using [`Self::style_mut]` + /// + /// You can use [`Ui::style_mut`] to change the style of a single [`Ui`]. pub fn set_style(&self, style: impl Into>) { self.options_mut(|opt| opt.style = style.into()); } @@ -1365,9 +1375,16 @@ impl Context { /// Allocate a texture. /// - /// In order to display an image you must convert it to a texture using this function. + /// This is for advanced users. + /// Most users should use [`crate::Ui::image`] or [`Self::try_load_texture`] + /// instead. /// - /// Make sure to only call this once for each image, i.e. NOT in your main GUI code. + /// In order to display an image you must convert it to a texture using this function. + /// The function will hand over the image data to the egui backend, which will + /// upload it to the GPU. + /// + /// ⚠️ Make sure to only call this ONCE for each image, i.e. NOT in your main GUI code. + /// The call is NOT immediate safe. /// /// The given name can be useful for later debugging, and will be visible if you call [`Self::texture_ui`]. /// @@ -1390,12 +1407,12 @@ impl Context { /// }); /// /// // Show the image: - /// ui.image(texture, texture.size_vec2()); + /// ui.image((texture.id(), texture.size_vec2())); /// } /// } /// ``` /// - /// Se also [`crate::ImageData`], [`crate::Ui::image`] and [`crate::ImageButton`]. + /// See also [`crate::ImageData`], [`crate::Ui::image`] and [`crate::Image`]. pub fn load_texture( &self, name: impl Into, @@ -1533,7 +1550,7 @@ impl Context { nodes, tree: Some(accesskit::Tree::new(root_id)), focus: has_focus.then(|| { - let focus_id = self.memory(|mem| mem.interaction.focus.id); + let focus_id = self.memory(|mem| mem.focus()); focus_id.map_or(root_id, |id| id.accesskit_id()) }), }); @@ -2070,14 +2087,15 @@ impl Context { let mut size = vec2(w as f32, h as f32); size *= (max_preview_size.x / size.x).min(1.0); size *= (max_preview_size.y / size.y).min(1.0); - ui.image(texture_id, size).on_hover_ui(|ui| { - // show larger on hover - let max_size = 0.5 * ui.ctx().screen_rect().size(); - let mut size = vec2(w as f32, h as f32); - size *= max_size.x / size.x.max(max_size.x); - size *= max_size.y / size.y.max(max_size.y); - ui.image(texture_id, size); - }); + ui.image(SizedTexture::new(texture_id, size)) + .on_hover_ui(|ui| { + // show larger on hover + let max_size = 0.5 * ui.ctx().screen_rect().size(); + let mut size = vec2(w as f32, h as f32); + size *= max_size.x / size.x.max(max_size.x); + size *= max_size.y / size.y.max(max_size.y); + ui.image(SizedTexture::new(texture_id, size)); + }); ui.label(format!("{w} x {h}")); ui.label(format!("{:.3} MB", meta.bytes_used() as f64 * 1e-6)); @@ -2290,60 +2308,93 @@ impl Context { impl Context { /// Associate some static bytes with a `uri`. /// - /// The same `uri` may be passed to [`Ui::image2`] later to load the bytes as an image. - pub fn include_static_bytes(&self, uri: &'static str, bytes: &'static [u8]) { - self.read(|ctx| ctx.loaders.include.insert_static(uri, bytes)); - } - - /// Associate some bytes with a `uri`. + /// The same `uri` may be passed to [`Ui::image`] later to load the bytes as an image. /// - /// The same `uri` may be passed to [`Ui::image2`] later to load the bytes as an image. - pub fn include_bytes(&self, uri: &'static str, bytes: impl Into>) { - self.read(|ctx| ctx.loaders.include.insert_shared(uri, bytes)); + /// By convention, the `uri` should start with `bytes://`. + /// Following that convention will lead to better error messages. + pub fn include_bytes(&self, uri: impl Into>, bytes: impl Into) { + self.loaders().include.insert(uri, bytes); } - /// Append an entry onto the chain of bytes loaders. + /// Returns `true` if the chain of bytes, image, or texture loaders + /// contains a loader with the given `id`. + pub fn is_loader_installed(&self, id: &str) -> bool { + let loaders = self.loaders(); + + loaders.bytes.lock().iter().any(|l| l.id() == id) + || loaders.image.lock().iter().any(|l| l.id() == id) + || loaders.texture.lock().iter().any(|l| l.id() == id) + } + + /// Add a new bytes loader. + /// + /// It will be tried first, before any already installed loaders. /// /// See [`load`] for more information. pub fn add_bytes_loader(&self, loader: Arc) { - self.write(|ctx| ctx.loaders.bytes.push(loader)); + self.loaders().bytes.lock().push(loader); } - /// Append an entry onto the chain of image loaders. + /// Add a new image loader. + /// + /// It will be tried first, before any already installed loaders. /// /// See [`load`] for more information. pub fn add_image_loader(&self, loader: Arc) { - self.write(|ctx| ctx.loaders.image.push(loader)); + self.loaders().image.lock().push(loader); } - /// Append an entry onto the chain of texture loaders. + /// Add a new texture loader. + /// + /// It will be tried first, before any already installed loaders. /// /// See [`load`] for more information. pub fn add_texture_loader(&self, loader: Arc) { - self.write(|ctx| ctx.loaders.texture.push(loader)); + self.loaders().texture.lock().push(loader); } /// Release all memory and textures related to the given image URI. /// /// If you attempt to load the image again, it will be reloaded from scratch. pub fn forget_image(&self, uri: &str) { - self.write(|ctx| { - use crate::load::BytesLoader as _; + use load::BytesLoader as _; - ctx.loaders.include.forget(uri); + crate::profile_function!(); - for loader in &ctx.loaders.bytes { - loader.forget(uri); - } + let loaders = self.loaders(); - for loader in &ctx.loaders.image { - loader.forget(uri); - } + loaders.include.forget(uri); + for loader in loaders.bytes.lock().iter() { + loader.forget(uri); + } + for loader in loaders.image.lock().iter() { + loader.forget(uri); + } + for loader in loaders.texture.lock().iter() { + loader.forget(uri); + } + } - for loader in &ctx.loaders.texture { - loader.forget(uri); - } - }); + /// Release all memory and textures related to images used in [`Ui::image`] or [`Image`]. + /// + /// If you attempt to load any images again, they will be reloaded from scratch. + pub fn forget_all_images(&self) { + use load::BytesLoader as _; + + crate::profile_function!(); + + let loaders = self.loaders(); + + loaders.include.forget_all(); + for loader in loaders.bytes.lock().iter() { + loader.forget_all(); + } + for loader in loaders.image.lock().iter() { + loader.forget_all(); + } + for loader in loaders.texture.lock().iter() { + loader.forget_all(); + } } /// Try loading the bytes from the given uri using any available bytes loaders. @@ -2358,21 +2409,27 @@ impl Context { /// # Errors /// This may fail with: /// - [`LoadError::NotSupported`][not_supported] if none of the registered loaders support loading the given `uri`. - /// - [`LoadError::Custom`][custom] if one of the loaders _does_ support loading the `uri`, but the loading process failed. + /// - [`LoadError::Loading`][custom] if one of the loaders _does_ support loading the `uri`, but the loading process failed. + /// + /// ⚠ May deadlock if called from within a `BytesLoader`! /// /// [not_supported]: crate::load::LoadError::NotSupported - /// [custom]: crate::load::LoadError::Custom + /// [custom]: crate::load::LoadError::Loading pub fn try_load_bytes(&self, uri: &str) -> load::BytesLoadResult { - self.read(|this| { - for loader in &this.loaders.bytes { - match loader.load(self, uri) { - Err(load::LoadError::NotSupported) => continue, - result => return result, - } - } + crate::profile_function!(); - Err(load::LoadError::NotSupported) - }) + let loaders = self.loaders(); + let bytes_loaders = loaders.bytes.lock(); + + // Try most recently added loaders first (hence `.rev()`) + for loader in bytes_loaders.iter().rev() { + match loader.load(self, uri) { + Err(load::LoadError::NotSupported) => continue, + result => return result, + } + } + + Err(load::LoadError::NoMatchingBytesLoader) } /// Try loading the image from the given uri using any available image loaders. @@ -2386,22 +2443,33 @@ impl Context { /// /// # Errors /// This may fail with: + /// - [`LoadError::NoImageLoaders`][no_image_loaders] if tbere are no registered image loaders. /// - [`LoadError::NotSupported`][not_supported] if none of the registered loaders support loading the given `uri`. - /// - [`LoadError::Custom`][custom] if one of the loaders _does_ support loading the `uri`, but the loading process failed. + /// - [`LoadError::Loading`][custom] if one of the loaders _does_ support loading the `uri`, but the loading process failed. /// + /// ⚠ May deadlock if called from within an `ImageLoader`! + /// + /// [no_image_loaders]: crate::load::LoadError::NoImageLoaders /// [not_supported]: crate::load::LoadError::NotSupported - /// [custom]: crate::load::LoadError::Custom + /// [custom]: crate::load::LoadError::Loading pub fn try_load_image(&self, uri: &str, size_hint: load::SizeHint) -> load::ImageLoadResult { - self.read(|this| { - for loader in &this.loaders.image { - match loader.load(self, uri, size_hint) { - Err(load::LoadError::NotSupported) => continue, - result => return result, - } - } + crate::profile_function!(); - Err(load::LoadError::NotSupported) - }) + let loaders = self.loaders(); + let image_loaders = loaders.image.lock(); + if image_loaders.is_empty() { + return Err(load::LoadError::NoImageLoaders); + } + + // Try most recently added loaders first (hence `.rev()`) + for loader in image_loaders.iter().rev() { + match loader.load(self, uri, size_hint) { + Err(load::LoadError::NotSupported) => continue, + result => return result, + } + } + + Err(load::LoadError::NoMatchingImageLoader) } /// Try loading the texture from the given uri using any available texture loaders. @@ -2416,26 +2484,38 @@ impl Context { /// # Errors /// This may fail with: /// - [`LoadError::NotSupported`][not_supported] if none of the registered loaders support loading the given `uri`. - /// - [`LoadError::Custom`][custom] if one of the loaders _does_ support loading the `uri`, but the loading process failed. + /// - [`LoadError::Loading`][custom] if one of the loaders _does_ support loading the `uri`, but the loading process failed. + /// + /// ⚠ May deadlock if called from within a `TextureLoader`! /// /// [not_supported]: crate::load::LoadError::NotSupported - /// [custom]: crate::load::LoadError::Custom + /// [custom]: crate::load::LoadError::Loading pub fn try_load_texture( &self, uri: &str, texture_options: TextureOptions, size_hint: load::SizeHint, ) -> load::TextureLoadResult { - self.read(|this| { - for loader in &this.loaders.texture { - match loader.load(self, uri, texture_options, size_hint) { - Err(load::LoadError::NotSupported) => continue, - result => return result, - } - } + crate::profile_function!(); - Err(load::LoadError::NotSupported) - }) + let loaders = self.loaders(); + let texture_loaders = loaders.texture.lock(); + + // Try most recently added loaders first (hence `.rev()`) + for loader in texture_loaders.iter().rev() { + match loader.load(self, uri, texture_options, size_hint) { + Err(load::LoadError::NotSupported) => continue, + result => return result, + } + } + + Err(load::LoadError::NoMatchingTextureLoader) + } + + /// The loaders of bytes, images, and textures. + pub fn loaders(&self) -> Arc { + crate::profile_function!(); + self.read(|this| this.loaders.clone()) } } diff --git a/crates/egui/src/data/input.rs b/crates/egui/src/data/input.rs index 2c4a55e28..cc22df283 100644 --- a/crates/egui/src/data/input.rs +++ b/crates/egui/src/data/input.rs @@ -1080,3 +1080,62 @@ impl From for TouchId { Self(id as u64) } } + +// ---------------------------------------------------------------------------- + +// TODO(emilk): generalize this to a proper event filter. +/// Controls which events that a focused widget will have exclusive access to. +/// +/// Currently this only controls a few special keyboard events, +/// but in the future this `struct` should be extended into a full callback thing. +/// +/// Any events not covered by the filter are given to the widget, but are not exclusive. +#[derive(Clone, Copy, Debug)] +pub struct EventFilter { + /// If `true`, pressing tab will act on the widget, + /// and NOT move focus away from the focused widget. + /// + /// Default: `false` + pub tab: bool, + + /// If `true`, pressing arrows will act on the widget, + /// and NOT move focus away from the focused widget. + /// + /// Default: `false` + pub arrows: bool, + + /// If `true`, pressing escape will act on the widget, + /// and NOT surrender focus from the focused widget. + /// + /// Default: `false` + pub escape: bool, +} + +#[allow(clippy::derivable_impls)] // let's be explicit +impl Default for EventFilter { + fn default() -> Self { + Self { + tab: false, + arrows: false, + escape: false, + } + } +} + +impl EventFilter { + pub fn matches(&self, event: &Event) -> bool { + if let Event::Key { key, .. } = event { + match key { + crate::Key::Tab => self.tab, + crate::Key::ArrowUp + | crate::Key::ArrowRight + | crate::Key::ArrowDown + | crate::Key::ArrowLeft => self.arrows, + crate::Key::Escape => self.escape, + _ => true, + } + } else { + true + } + } +} diff --git a/crates/egui/src/input_state.rs b/crates/egui/src/input_state.rs index 031a75d19..c503f5465 100644 --- a/crates/egui/src/input_state.rs +++ b/crates/egui/src/input_state.rs @@ -490,6 +490,15 @@ impl InputState { pub fn num_accesskit_action_requests(&self, id: crate::Id, action: accesskit::Action) -> usize { self.accesskit_action_requests(id, action).count() } + + /// Get all events that matches the given filter. + pub fn filtered_events(&self, filter: &EventFilter) -> Vec { + self.events + .iter() + .filter(|event| filter.matches(event)) + .cloned() + .collect() + } } // ---------------------------------------------------------------------------- diff --git a/crates/egui/src/lib.rs b/crates/egui/src/lib.rs index aeeb501eb..1330fee04 100644 --- a/crates/egui/src/lib.rs +++ b/crates/egui/src/lib.rs @@ -84,7 +84,7 @@ //! ui.separator(); //! //! # let my_image = egui::TextureId::default(); -//! ui.image(my_image, [640.0, 480.0]); +//! ui.image((my_image, egui::Vec2::new(640.0, 480.0))); //! //! ui.collapsing("Click to see what is hidden!", |ui| { //! ui.label("Not much, as it turns out"); @@ -107,9 +107,9 @@ //! //! Most likely you are using an existing `egui` backend/integration such as [`eframe`](https://docs.rs/eframe), [`bevy_egui`](https://docs.rs/bevy_egui), //! or [`egui-miniquad`](https://github.com/not-fl3/egui-miniquad), -//! but if you want to integrate `egui` into a new game engine, this is the section for you. +//! but if you want to integrate `egui` into a new game engine or graphics backend, this is the section for you. //! -//! To write your own integration for egui you need to do this: +//! You need to collect [`RawInput`] and handle [`FullOutput`]. The basic structure is this: //! //! ``` no_run //! # fn handle_platform_output(_: egui::PlatformOutput) {} @@ -135,6 +135,31 @@ //! } //! ``` //! +//! For a reference OpenGL renderer, see [the `egui_glow` painter](https://github.com/emilk/egui/blob/master/crates/egui_glow/src/painter.rs). +//! +//! +//! ### Debugging your renderer +//! +//! #### Things look jagged +//! +//! * Turn off backface culling. +//! +//! #### My text is blurry +//! +//! * Make sure you set the proper `pixels_per_point` in the input to egui. +//! * Make sure the texture sampler is not off by half a pixel. Try nearest-neighbor sampler to check. +//! +//! #### My windows are too transparent or too dark +//! +//! * egui uses premultiplied alpha, so make sure your blending function is `(ONE, ONE_MINUS_SRC_ALPHA)`. +//! * Make sure your texture sampler is clamped (`GL_CLAMP_TO_EDGE`). +//! * egui prefers linear color spaces for all blending so: +//! * Use an sRGBA-aware texture if available (e.g. `GL_SRGB8_ALPHA8`). +//! * Otherwise: remember to decode gamma in the fragment shader. +//! * Decode the gamma of the incoming vertex colors in your vertex shader. +//! * Turn on sRGBA/linear framebuffer if available (`GL_FRAMEBUFFER_SRGB`). +//! * Otherwise: gamma-encode the colors before you write them again. +//! //! //! # Understanding immediate mode //! @@ -401,6 +426,34 @@ pub fn warn_if_debug_build(ui: &mut crate::Ui) { // ---------------------------------------------------------------------------- +/// Include an image in the binary. +/// +/// This is a wrapper over `include_bytes!`, and behaves in the same way. +/// +/// It produces an [`ImageSource`] which can be used directly in [`Ui::image`] or [`Image::new`]: +/// +/// ``` +/// # egui::__run_test_ui(|ui| { +/// ui.image(egui::include_image!("../assets/ferris.png")); +/// ui.add( +/// egui::Image::new(egui::include_image!("../assets/ferris.png")) +/// .rounding(egui::Rounding::same(6.0)) +/// ); +/// +/// let image_source: egui::ImageSource = egui::include_image!("../assets/ferris.png"); +/// assert_eq!(image_source.uri(), Some("bytes://../assets/ferris.png")); +/// # }); +/// ``` +#[macro_export] +macro_rules! include_image { + ($path: literal) => { + $crate::ImageSource::Bytes( + ::std::borrow::Cow::Borrowed(concat!("bytes://", $path)), // uri + $crate::load::Bytes::Static(include_bytes!($path)), + ) + }; +} + /// Create a [`Hyperlink`](crate::Hyperlink) to the current [`file!()`] (and line) on Github /// /// ``` @@ -605,8 +658,8 @@ mod profiling_scopes { /// Profiling macro for feature "puffin" macro_rules! profile_function { ($($arg: tt)*) => { - #[cfg(not(target_arch = "wasm32"))] // Disabled on web because of the coarse 1ms clock resolution there. #[cfg(feature = "puffin")] + #[cfg(not(target_arch = "wasm32"))] // Disabled on web because of the coarse 1ms clock resolution there. puffin::profile_function!($($arg)*); }; } @@ -615,12 +668,13 @@ mod profiling_scopes { /// Profiling macro for feature "puffin" macro_rules! profile_scope { ($($arg: tt)*) => { - #[cfg(not(target_arch = "wasm32"))] // Disabled on web because of the coarse 1ms clock resolution there. #[cfg(feature = "puffin")] + #[cfg(not(target_arch = "wasm32"))] // Disabled on web because of the coarse 1ms clock resolution there. puffin::profile_scope!($($arg)*); }; } pub(crate) use profile_scope; } +#[allow(unused_imports)] pub(crate) use profiling_scopes::*; diff --git a/crates/egui/src/load.rs b/crates/egui/src/load.rs index 3de8963bb..6f277cb5e 100644 --- a/crates/egui/src/load.rs +++ b/crates/egui/src/load.rs @@ -1,8 +1,12 @@ -//! Types and traits related to image loading. +//! # Image loading //! -//! If you just want to load some images, see [`egui_extras`](https://crates.io/crates/egui_extras/), -//! which contains reasonable default implementations of these traits. You can get started quickly -//! using [`egui_extras::loaders::install`](https://docs.rs/egui_extras/latest/egui_extras/loaders/fn.install.html). +//! If you just want to display some images, [`egui_extras`](https://crates.io/crates/egui_extras/) +//! will get you up and running quickly with its reasonable default implementations of the traits described below. +//! +//! 1. Add [`egui_extras`](https://crates.io/crates/egui_extras/) as a dependency with the `all_loaders` feature. +//! 2. Add a call to [`egui_extras::install_image_loaders`](https://docs.rs/egui_extras/latest/egui_extras/loaders/fn.install.html) +//! in your app's setup code. +//! 3. Use [`Ui::image`][`crate::ui::Ui::image`] with some [`ImageSource`][`crate::ImageSource`]. //! //! ## Loading process //! @@ -14,13 +18,13 @@ //! The different kinds of loaders represent different layers in the loading process: //! //! ```text,ignore -//! ui.image2("file://image.png") -//! └► ctx.try_load_texture("file://image.png", ...) -//! └► TextureLoader::load("file://image.png", ...) -//! └► ctx.try_load_image("file://image.png", ...) -//! └► ImageLoader::load("file://image.png", ...) -//! └► ctx.try_load_bytes("file://image.png", ...) -//! └► BytesLoader::load("file://image.png", ...) +//! ui.image("file://image.png") +//! └► Context::try_load_texture +//! └► TextureLoader::load +//! └► Context::try_load_image +//! └► ImageLoader::load +//! └► Context::try_load_bytes +//! └► BytesLoader::load //! ``` //! //! As each layer attempts to load the URI, it first asks the layer below it @@ -48,28 +52,65 @@ //! For example, a loader may determine that it doesn't support loading a specific URI //! if the protocol does not match what it expects. +mod bytes_loader; +mod texture_loader; + use crate::Context; use ahash::HashMap; use epaint::mutex::Mutex; +use epaint::util::FloatOrd; +use epaint::util::OrderedFloat; use epaint::TextureHandle; use epaint::{textures::TextureOptions, ColorImage, TextureId, Vec2}; +use std::borrow::Cow; +use std::fmt::Debug; use std::ops::Deref; use std::{error::Error as StdError, fmt::Display, sync::Arc}; +pub use self::bytes_loader::DefaultBytesLoader; +pub use self::texture_loader::DefaultTextureLoader; + +/// Represents a failed attempt at loading an image. #[derive(Clone, Debug)] pub enum LoadError { - /// This loader does not support this protocol or image format. + /// Programmer error: There are no image loaders installed. + NoImageLoaders, + + /// A specific loader does not support this schema, protocol or image format. NotSupported, - /// A custom error message (e.g. "File not found: foo.png"). - Custom(String), + /// Programmer error: Failed to find the bytes for this image because + /// there was no [`BytesLoader`] supporting the schema. + NoMatchingBytesLoader, + + /// Programmer error: Failed to parse the bytes as an image because + /// there was no [`ImageLoader`] supporting the schema. + NoMatchingImageLoader, + + /// Programmer error: no matching [`TextureLoader`]. + /// Because of the [`DefaultTextureLoader`], this error should never happen. + NoMatchingTextureLoader, + + /// Runtime error: Loading was attempted, but failed (e.g. "File not found"). + Loading(String), } impl Display for LoadError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - LoadError::NotSupported => f.write_str("not supported"), - LoadError::Custom(message) => f.write_str(message), + Self::NoImageLoaders => f.write_str( + "No image loaders are installed. If you're trying to load some images \ + for the first time, follow the steps outlined in https://docs.rs/egui/latest/egui/load/index.html"), + + Self::NoMatchingBytesLoader => f.write_str("No matching BytesLoader. Either you need to call Context::include_bytes, or install some more bytes loaders, e.g. using egui_extras."), + + Self::NoMatchingImageLoader => f.write_str("No matching ImageLoader. Either you need to call Context::include_bytes, or install some more bytes loaders, e.g. using egui_extras."), + + Self::NoMatchingTextureLoader => f.write_str("No matching TextureLoader. Did you remove the default one?"), + + Self::NotSupported => f.write_str("Iagge schema or URI not supported by this loader"), + + Self::Loading(message) => f.write_str(message), } } } @@ -85,11 +126,10 @@ pub type Result = std::result::Result; /// All variants will preserve the original aspect ratio. /// /// Similar to `usvg::FitTo`. -#[derive(Default, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum SizeHint { - /// Keep original size. - #[default] - Original, + /// Scale original size by some factor. + Scale(OrderedFloat), /// Scale to width. Width(u32), @@ -101,22 +141,36 @@ pub enum SizeHint { Size(u32, u32), } +impl Default for SizeHint { + fn default() -> Self { + Self::Scale(1.0.ord()) + } +} + impl From for SizeHint { fn from(value: Vec2) -> Self { Self::Size(value.x.round() as u32, value.y.round() as u32) } } -// TODO: API for querying bytes caches in each loader - -pub type Size = [usize; 2]; - +/// Represents a byte buffer. +/// +/// This is essentially `Cow<'static, [u8]>` but with the `Owned` variant being an `Arc`. #[derive(Clone)] pub enum Bytes { Static(&'static [u8]), Shared(Arc<[u8]>), } +impl Debug for Bytes { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Static(arg0) => f.debug_tuple("Static").field(&arg0.len()).finish(), + Self::Shared(arg0) => f.debug_tuple("Shared").field(&arg0.len()).finish(), + } + } +} + impl From<&'static [u8]> for Bytes { #[inline] fn from(value: &'static [u8]) -> Self { @@ -124,6 +178,13 @@ impl From<&'static [u8]> for Bytes { } } +impl From<&'static [u8; N]> for Bytes { + #[inline] + fn from(value: &'static [u8; N]) -> Self { + Bytes::Static(value) + } +} + impl From> for Bytes { #[inline] fn from(value: Arc<[u8]>) -> Self { @@ -131,6 +192,13 @@ impl From> for Bytes { } } +impl From> for Bytes { + #[inline] + fn from(value: Vec) -> Self { + Bytes::Shared(value.into()) + } +} + impl AsRef<[u8]> for Bytes { #[inline] fn as_ref(&self) -> &[u8] { @@ -150,27 +218,60 @@ impl Deref for Bytes { } } +/// Represents bytes which are currently being loaded. +/// +/// This is similar to [`std::task::Poll`], but the `Pending` variant +/// contains an optional `size`, which may be used during layout to +/// pre-allocate space the image. #[derive(Clone)] pub enum BytesPoll { /// Bytes are being loaded. Pending { /// Set if known (e.g. from a HTTP header, or by parsing the image file header). - size: Option, + size: Option, }, /// Bytes are loaded. Ready { /// Set if known (e.g. from a HTTP header, or by parsing the image file header). - size: Option, + size: Option, /// File contents, e.g. the contents of a `.png`. bytes: Bytes, + + /// Mime type of the content, e.g. `image/png`. + /// + /// Set if known (e.g. from `Content-Type` HTTP header). + mime: Option, }, } +/// Used to get a unique ID when implementing one of the loader traits: [`BytesLoader::id`], [`ImageLoader::id`], and [`TextureLoader::id`]. +/// +/// This just expands to `module_path!()` concatenated with the given type name. +#[macro_export] +macro_rules! generate_loader_id { + ($ty:ident) => { + concat!(module_path!(), "::", stringify!($ty)) + }; +} +pub use crate::generate_loader_id; + pub type BytesLoadResult = Result; +/// Represents a loader capable of loading raw unstructured bytes from somewhere, +/// e.g. from disk or network. +/// +/// It should also provide any subsequent loaders a hint for what the bytes may +/// represent using [`BytesPoll::Ready::mime`], if it can be inferred. +/// +/// Implementations are expected to cache at least each `URI`. pub trait BytesLoader { + /// Unique ID of this loader. + /// + /// To reduce the chance of collisions, use [`generate_loader_id`] for this. + fn id(&self) -> &str; + /// Try loading the bytes from the given uri. /// /// Implementations should call `ctx.request_repaint` to wake up the ui @@ -182,7 +283,7 @@ pub trait BytesLoader { /// # Errors /// This may fail with: /// - [`LoadError::NotSupported`] if the loader does not support loading `uri`. - /// - [`LoadError::Custom`] if the loading process failed. + /// - [`LoadError::Loading`] if the loading process failed. fn load(&self, ctx: &Context, uri: &str) -> BytesLoadResult; /// Forget the given `uri`. @@ -191,6 +292,12 @@ pub trait BytesLoader { /// so that it may be fully reloaded. fn forget(&self, uri: &str); + /// Forget all URIs ever given to this loader. + /// + /// If the loader caches any URIs, the entire cache should be cleared, + /// so that all of them may be fully reloaded. + fn forget_all(&self); + /// Implementations may use this to perform work at the end of a frame, /// such as evicting unused entries from a cache. fn end_frame(&self, frame_index: usize) { @@ -201,12 +308,17 @@ pub trait BytesLoader { fn byte_size(&self) -> usize; } +/// Represents an image which is currently being loaded. +/// +/// This is similar to [`std::task::Poll`], but the `Pending` variant +/// contains an optional `size`, which may be used during layout to +/// pre-allocate space the image. #[derive(Clone)] pub enum ImagePoll { /// Image is loading. Pending { /// Set if known (e.g. from a HTTP header, or by parsing the image file header). - size: Option, + size: Option, }, /// Image is loaded. @@ -215,7 +327,18 @@ pub enum ImagePoll { pub type ImageLoadResult = Result; +/// An `ImageLoader` decodes raw bytes into a [`ColorImage`]. +/// +/// Implementations are expected to cache at least each `URI`. pub trait ImageLoader { + /// Unique ID of this loader. + /// + /// To reduce the chance of collisions, include `module_path!()` as part of this ID. + /// + /// For example: `concat!(module_path!(), "::MyLoader")` + /// for `my_crate::my_loader::MyLoader`. + fn id(&self) -> &str; + /// Try loading the image from the given uri. /// /// Implementations should call `ctx.request_repaint` to wake up the ui @@ -227,7 +350,7 @@ pub trait ImageLoader { /// # Errors /// This may fail with: /// - [`LoadError::NotSupported`] if the loader does not support loading `uri`. - /// - [`LoadError::Custom`] if the loading process failed. + /// - [`LoadError::Loading`] if the loading process failed. fn load(&self, ctx: &Context, uri: &str, size_hint: SizeHint) -> ImageLoadResult; /// Forget the given `uri`. @@ -236,6 +359,12 @@ pub trait ImageLoader { /// so that it may be fully reloaded. fn forget(&self, uri: &str); + /// Forget all URIs ever given to this loader. + /// + /// If the loader caches any URIs, the entire cache should be cleared, + /// so that all of them may be fully reloaded. + fn forget_all(&self); + /// Implementations may use this to perform work at the end of a frame, /// such as evicting unused entries from a cache. fn end_frame(&self, frame_index: usize) { @@ -247,36 +376,91 @@ pub trait ImageLoader { } /// A texture with a known size. -#[derive(Clone)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct SizedTexture { pub id: TextureId, - pub size: Size, + pub size: Vec2, } impl SizedTexture { + /// Create a [`SizedTexture`] from a texture `id` with a specific `size`. + pub fn new(id: impl Into, size: impl Into) -> Self { + Self { + id: id.into(), + size: size.into(), + } + } + + /// Fetch the [id][`SizedTexture::id`] and [size][`SizedTexture::size`] from a [`TextureHandle`]. pub fn from_handle(handle: &TextureHandle) -> Self { + let size = handle.size(); Self { id: handle.id(), - size: handle.size(), + size: Vec2::new(size[0] as f32, size[1] as f32), } } } -#[derive(Clone)] +impl From<(TextureId, Vec2)> for SizedTexture { + #[inline] + fn from((id, size): (TextureId, Vec2)) -> Self { + Self { id, size } + } +} + +impl<'a> From<&'a TextureHandle> for SizedTexture { + fn from(handle: &'a TextureHandle) -> Self { + Self::from_handle(handle) + } +} + +/// Represents a texture is currently being loaded. +/// +/// This is similar to [`std::task::Poll`], but the `Pending` variant +/// contains an optional `size`, which may be used during layout to +/// pre-allocate space the image. +#[derive(Clone, Copy)] pub enum TexturePoll { /// Texture is loading. Pending { /// Set if known (e.g. from a HTTP header, or by parsing the image file header). - size: Option, + size: Option, }, /// Texture is loaded. Ready { texture: SizedTexture }, } +impl TexturePoll { + pub fn size(self) -> Option { + match self { + TexturePoll::Pending { size } => size, + TexturePoll::Ready { texture } => Some(texture.size), + } + } +} + pub type TextureLoadResult = Result; +/// A `TextureLoader` uploads a [`ColorImage`] to the GPU, returning a [`SizedTexture`]. +/// +/// `egui` comes with an implementation that uses [`Context::load_texture`], +/// which just asks the egui backend to upload the image to the GPU. +/// +/// You can implement this trait if you do your own uploading of images to the GPU. +/// For instance, you can use this to refer to textures in a game engine that egui +/// doesn't otherwise know about. +/// +/// Implementations are expected to cache each combination of `(URI, TextureOptions)`. pub trait TextureLoader { + /// Unique ID of this loader. + /// + /// To reduce the chance of collisions, include `module_path!()` as part of this ID. + /// + /// For example: `concat!(module_path!(), "::MyLoader")` + /// for `my_crate::my_loader::MyLoader`. + fn id(&self) -> &str; + /// Try loading the texture from the given uri. /// /// Implementations should call `ctx.request_repaint` to wake up the ui @@ -288,7 +472,7 @@ pub trait TextureLoader { /// # Errors /// This may fail with: /// - [`LoadError::NotSupported`] if the loader does not support loading `uri`. - /// - [`LoadError::Custom`] if the loading process failed. + /// - [`LoadError::Loading`] if the loading process failed. fn load( &self, ctx: &Context, @@ -303,6 +487,12 @@ pub trait TextureLoader { /// so that it may be fully reloaded. fn forget(&self, uri: &str); + /// Forget all URIs ever given to this loader. + /// + /// If the loader caches any URIs, the entire cache should be cleared, + /// so that all of them may be fully reloaded. + fn forget_all(&self); + /// Implementations may use this to perform work at the end of a frame, /// such as evicting unused entries from a cache. fn end_frame(&self, frame_index: usize) { @@ -313,104 +503,27 @@ pub trait TextureLoader { fn byte_size(&self) -> usize; } -#[derive(Default)] -pub(crate) struct DefaultBytesLoader { - cache: Mutex>, -} +type BytesLoaderImpl = Arc; +type ImageLoaderImpl = Arc; +type TextureLoaderImpl = Arc; -impl DefaultBytesLoader { - pub(crate) fn insert_static(&self, uri: &'static str, bytes: &'static [u8]) { - self.cache - .lock() - .entry(uri) - .or_insert_with(|| Bytes::Static(bytes)); - } - - pub(crate) fn insert_shared(&self, uri: &'static str, bytes: impl Into>) { - self.cache - .lock() - .entry(uri) - .or_insert_with(|| Bytes::Shared(bytes.into())); - } -} - -impl BytesLoader for DefaultBytesLoader { - fn load(&self, _: &Context, uri: &str) -> BytesLoadResult { - match self.cache.lock().get(uri).cloned() { - Some(bytes) => Ok(BytesPoll::Ready { size: None, bytes }), - None => Err(LoadError::NotSupported), - } - } - - fn forget(&self, uri: &str) { - let _ = self.cache.lock().remove(uri); - } - - fn byte_size(&self) -> usize { - self.cache.lock().values().map(|bytes| bytes.len()).sum() - } -} - -#[derive(Default)] -struct DefaultTextureLoader { - cache: Mutex>, -} - -impl TextureLoader for DefaultTextureLoader { - fn load( - &self, - ctx: &Context, - uri: &str, - texture_options: TextureOptions, - size_hint: SizeHint, - ) -> TextureLoadResult { - let mut cache = self.cache.lock(); - if let Some(handle) = cache.get(&(uri.into(), texture_options)) { - let texture = SizedTexture::from_handle(handle); - Ok(TexturePoll::Ready { texture }) - } else { - match ctx.try_load_image(uri, size_hint)? { - ImagePoll::Pending { size } => Ok(TexturePoll::Pending { size }), - ImagePoll::Ready { image } => { - let handle = ctx.load_texture(uri, image, texture_options); - let texture = SizedTexture::from_handle(&handle); - cache.insert((uri.into(), texture_options), handle); - Ok(TexturePoll::Ready { texture }) - } - } - } - } - - fn forget(&self, uri: &str) { - self.cache.lock().retain(|(u, _), _| u != uri); - } - - fn end_frame(&self, _: usize) {} - - fn byte_size(&self) -> usize { - self.cache - .lock() - .values() - .map(|texture| texture.byte_size()) - .sum() - } -} - -pub(crate) struct Loaders { +#[derive(Clone)] +/// The loaders of bytes, images, and textures. +pub struct Loaders { pub include: Arc, - pub bytes: Vec>, - pub image: Vec>, - pub texture: Vec>, + pub bytes: Mutex>, + pub image: Mutex>, + pub texture: Mutex>, } impl Default for Loaders { fn default() -> Self { let include = Arc::new(DefaultBytesLoader::default()); Self { - bytes: vec![include.clone()], - image: Vec::new(), + bytes: Mutex::new(vec![include.clone()]), + image: Mutex::new(Vec::new()), // By default we only include `DefaultTextureLoader`. - texture: vec![Arc::new(DefaultTextureLoader::default())], + texture: Mutex::new(vec![Arc::new(DefaultTextureLoader::default())]), include, } } diff --git a/crates/egui/src/load/bytes_loader.rs b/crates/egui/src/load/bytes_loader.rs new file mode 100644 index 000000000..3ab467949 --- /dev/null +++ b/crates/egui/src/load/bytes_loader.rs @@ -0,0 +1,69 @@ +use super::*; + +/// Maps URI:s to [`Bytes`], e.g. found with `include_bytes!`. +/// +/// By convention, the URI:s should be prefixed with `bytes://`. +#[derive(Default)] +pub struct DefaultBytesLoader { + cache: Mutex, Bytes>>, +} + +impl DefaultBytesLoader { + pub fn insert(&self, uri: impl Into>, bytes: impl Into) { + self.cache + .lock() + .entry(uri.into()) + .or_insert_with_key(|_uri| { + let bytes: Bytes = bytes.into(); + + #[cfg(feature = "log")] + log::trace!("loaded {} bytes for uri {_uri:?}", bytes.len()); + + bytes + }); + } +} + +impl BytesLoader for DefaultBytesLoader { + fn id(&self) -> &str { + generate_loader_id!(DefaultBytesLoader) + } + + fn load(&self, _: &Context, uri: &str) -> BytesLoadResult { + // We accept uri:s that don't start with `bytes://` too… for now. + match self.cache.lock().get(uri).cloned() { + Some(bytes) => Ok(BytesPoll::Ready { + size: None, + bytes, + mime: None, + }), + None => { + if uri.starts_with("bytes://") { + Err(LoadError::Loading( + "Bytes not found. Did you forget to call Context::include_bytes?".into(), + )) + } else { + Err(LoadError::NotSupported) + } + } + } + } + + fn forget(&self, uri: &str) { + #[cfg(feature = "log")] + log::trace!("forget {uri:?}"); + + let _ = self.cache.lock().remove(uri); + } + + fn forget_all(&self) { + #[cfg(feature = "log")] + log::trace!("forget all"); + + self.cache.lock().clear(); + } + + fn byte_size(&self) -> usize { + self.cache.lock().values().map(|bytes| bytes.len()).sum() + } +} diff --git a/crates/egui/src/load/texture_loader.rs b/crates/egui/src/load/texture_loader.rs new file mode 100644 index 000000000..89d616e47 --- /dev/null +++ b/crates/egui/src/load/texture_loader.rs @@ -0,0 +1,60 @@ +use super::*; + +#[derive(Default)] +pub struct DefaultTextureLoader { + cache: Mutex>, +} + +impl TextureLoader for DefaultTextureLoader { + fn id(&self) -> &str { + crate::generate_loader_id!(DefaultTextureLoader) + } + + fn load( + &self, + ctx: &Context, + uri: &str, + texture_options: TextureOptions, + size_hint: SizeHint, + ) -> TextureLoadResult { + let mut cache = self.cache.lock(); + if let Some(handle) = cache.get(&(uri.into(), texture_options)) { + let texture = SizedTexture::from_handle(handle); + Ok(TexturePoll::Ready { texture }) + } else { + match ctx.try_load_image(uri, size_hint)? { + ImagePoll::Pending { size } => Ok(TexturePoll::Pending { size }), + ImagePoll::Ready { image } => { + let handle = ctx.load_texture(uri, image, texture_options); + let texture = SizedTexture::from_handle(&handle); + cache.insert((uri.into(), texture_options), handle); + Ok(TexturePoll::Ready { texture }) + } + } + } + } + + fn forget(&self, uri: &str) { + #[cfg(feature = "log")] + log::trace!("forget {uri:?}"); + + self.cache.lock().retain(|(u, _), _| u != uri); + } + + fn forget_all(&self) { + #[cfg(feature = "log")] + log::trace!("forget all"); + + self.cache.lock().clear(); + } + + fn end_frame(&self, _: usize) {} + + fn byte_size(&self) -> usize { + self.cache + .lock() + .values() + .map(|texture| texture.byte_size()) + .sum() + } +} diff --git a/crates/egui/src/memory.rs b/crates/egui/src/memory.rs index 88d40186b..575ffea03 100644 --- a/crates/egui/src/memory.rs +++ b/crates/egui/src/memory.rs @@ -1,4 +1,6 @@ -use crate::{area, window, Id, IdMap, InputState, LayerId, Pos2, Rect, Style, ViewportId}; +use crate::{ + area, window, EventFilter, Id, IdMap, InputState, LayerId, Pos2, Rect, Style, ViewportId, +}; use ahash::HashMap; use epaint::{emath::Rangef, vec2, Vec2}; @@ -236,7 +238,7 @@ pub(crate) struct Interaction { #[derive(Clone, Debug, Default)] pub(crate) struct Focus { /// The widget with keyboard focus (i.e. a text input field). - pub(crate) id: Option, + focused_widget: Option, /// What had keyboard focus previous frame? id_previous_frame: Option, @@ -254,9 +256,6 @@ pub(crate) struct Focus { /// The last widget interested in focus. last_interested: Option, - /// If `true`, pressing tab will NOT move focus away from the current widget. - is_focus_locked: bool, - /// Set when looking for widget with navigational keys like arrows, tab, shift+tab focus_direction: FocusDirection, @@ -264,6 +263,22 @@ pub(crate) struct Focus { focus_widgets_cache: IdMap, } +/// The widget with focus. +#[derive(Clone, Copy, Debug)] +struct FocusWidget { + pub id: Id, + pub filter: EventFilter, +} + +impl FocusWidget { + pub fn new(id: Id) -> Self { + Self { + id, + filter: Default::default(), + } + } +} + impl Interaction { /// Are we currently clicking or dragging an egui widget? pub fn is_using_pointer(&self) -> bool { @@ -295,14 +310,15 @@ impl Interaction { impl Focus { /// Which widget currently has keyboard focus? pub fn focused(&self) -> Option { - self.id + self.focused_widget.as_ref().map(|w| w.id) } fn begin_frame(&mut self, new_input: &crate::data::input::RawInput) { - self.id_previous_frame = self.id; + self.id_previous_frame = self.focused(); if let Some(id) = self.id_next_frame.take() { - self.id = Some(id); + self.focused_widget = Some(FocusWidget::new(id)); } + let event_filter = self.focused_widget.map(|w| w.filter).unwrap_or_default(); #[cfg(feature = "accesskit")] { @@ -312,37 +328,35 @@ impl Focus { self.focus_direction = FocusDirection::None; for event in &new_input.events { - if let crate::Event::Key { - key, - pressed: true, - modifiers, - .. - } = event - { - if let Some(cardinality) = match key { - crate::Key::ArrowUp => Some(FocusDirection::Up), - crate::Key::ArrowRight => Some(FocusDirection::Right), - crate::Key::ArrowDown => Some(FocusDirection::Down), - crate::Key::ArrowLeft => Some(FocusDirection::Left), - crate::Key::Tab => { - if !self.is_focus_locked { + if !event_filter.matches(event) { + if let crate::Event::Key { + key, + pressed: true, + modifiers, + .. + } = event + { + if let Some(cardinality) = match key { + crate::Key::ArrowUp => Some(FocusDirection::Up), + crate::Key::ArrowRight => Some(FocusDirection::Right), + crate::Key::ArrowDown => Some(FocusDirection::Down), + crate::Key::ArrowLeft => Some(FocusDirection::Left), + + crate::Key::Tab => { if modifiers.shift { Some(FocusDirection::Previous) } else { Some(FocusDirection::Next) } - } else { - None } + crate::Key::Escape => { + self.focused_widget = None; + Some(FocusDirection::None) + } + _ => None, + } { + self.focus_direction = cardinality; } - crate::Key::Escape => { - self.id = None; - self.is_focus_locked = false; - Some(FocusDirection::None) - } - _ => None, - } { - self.focus_direction = cardinality; } } @@ -363,17 +377,17 @@ impl Focus { pub(crate) fn end_frame(&mut self, used_ids: &IdMap) { if self.focus_direction.is_cardinal() { if let Some(found_widget) = self.find_widget_in_direction(used_ids) { - self.id = Some(found_widget); + self.focused_widget = Some(FocusWidget::new(found_widget)); } } - if let Some(id) = self.id { + if let Some(focused_widget) = self.focused_widget { // Allow calling `request_focus` one frame and not using it until next frame - let recently_gained_focus = self.id_previous_frame != Some(id); + let recently_gained_focus = self.id_previous_frame != Some(focused_widget.id); - if !recently_gained_focus && !used_ids.contains_key(&id) { + if !recently_gained_focus && !used_ids.contains_key(&focused_widget.id) { // Dead-mans-switch: the widget with focus has disappeared! - self.id = None; + self.focused_widget = None; } } } @@ -386,7 +400,7 @@ impl Focus { #[cfg(feature = "accesskit")] { if self.id_requested_by_accesskit == Some(id.accesskit_id()) { - self.id = Some(id); + self.focused_widget = Some(FocusWidget::new(id)); self.id_requested_by_accesskit = None; self.give_to_next = false; self.reset_focus(); @@ -399,23 +413,23 @@ impl Focus { .or_insert(Rect::EVERYTHING); if self.give_to_next && !self.had_focus_last_frame(id) { - self.id = Some(id); + self.focused_widget = Some(FocusWidget::new(id)); self.give_to_next = false; - } else if self.id == Some(id) { - if self.focus_direction == FocusDirection::Next && !self.is_focus_locked { - self.id = None; + } else if self.focused() == Some(id) { + if self.focus_direction == FocusDirection::Next { + self.focused_widget = None; self.give_to_next = true; self.reset_focus(); - } else if self.focus_direction == FocusDirection::Previous && !self.is_focus_locked { + } else if self.focus_direction == FocusDirection::Previous { self.id_next_frame = self.last_interested; // frame-delay so gained_focus works self.reset_focus(); } } else if self.focus_direction == FocusDirection::Next - && self.id.is_none() + && self.focused_widget.is_none() && !self.give_to_next { // nothing has focus and the user pressed tab - give focus to the first widgets that wants it: - self.id = Some(id); + self.focused_widget = Some(FocusWidget::new(id)); self.reset_focus(); } @@ -441,7 +455,7 @@ impl Focus { } } - let Some(focus_id) = self.id else { + let Some(current_focused) = self.focused_widget else { return None; }; @@ -466,7 +480,7 @@ impl Focus { } }); - let Some(current_rect) = self.focus_widgets_cache.get(&focus_id) else { + let Some(current_rect) = self.focus_widgets_cache.get(¤t_focused.id) else { return None; }; @@ -474,7 +488,7 @@ impl Focus { let mut best_id = None; for (candidate_id, candidate_rect) in &self.focus_widgets_cache { - if Some(*candidate_id) == self.id { + if *candidate_id == current_focused.id { continue; } @@ -605,46 +619,58 @@ impl Memory { /// from the window and back. #[inline(always)] pub fn has_focus(&self, id: Id) -> bool { - self.interaction.focus.id == Some(id) + self.interaction.focus.focused() == Some(id) } /// Which widget has keyboard focus? pub fn focus(&self) -> Option { - self.interaction.focus.id + self.interaction.focus.focused() } - /// Prevent keyboard focus from moving away from this widget even if users presses the tab key. + /// Set an event filter for a widget. + /// + /// This allows you to control whether the widget will loose focus + /// when the user presses tab, arrow keys, or escape. + /// /// You must first give focus to the widget before calling this. - pub fn lock_focus(&mut self, id: Id, lock_focus: bool) { + pub fn set_focus_lock_filter(&mut self, id: Id, event_filter: EventFilter) { if self.had_focus_last_frame(id) && self.has_focus(id) { - self.interaction.focus.is_focus_locked = lock_focus; + if let Some(focused) = &mut self.interaction.focus.focused_widget { + if focused.id == id { + focused.filter = event_filter; + } + } } } - /// Is the keyboard focus locked on this widget? If so the focus won't move even if the user presses the tab key. - pub fn has_lock_focus(&self, id: Id) -> bool { - if self.had_focus_last_frame(id) && self.has_focus(id) { - self.interaction.focus.is_focus_locked - } else { - false - } + /// Set an event filter for a widget. + /// + /// You must first give focus to the widget before calling this. + #[deprecated = "Use set_focus_lock_filter instead"] + pub fn lock_focus(&mut self, id: Id, lock_focus: bool) { + self.set_focus_lock_filter( + id, + EventFilter { + tab: lock_focus, + arrows: lock_focus, + escape: false, + }, + ); } /// Give keyboard focus to a specific widget. /// See also [`crate::Response::request_focus`]. #[inline(always)] pub fn request_focus(&mut self, id: Id) { - self.interaction.focus.id = Some(id); - self.interaction.focus.is_focus_locked = false; + self.interaction.focus.focused_widget = Some(FocusWidget::new(id)); } /// Surrender keyboard focus for a specific widget. /// See also [`crate::Response::surrender_focus`]. #[inline(always)] pub fn surrender_focus(&mut self, id: Id) { - if self.interaction.focus.id == Some(id) { - self.interaction.focus.id = None; - self.interaction.focus.is_focus_locked = false; + if self.interaction.focus.focused() == Some(id) { + self.interaction.focus.focused_widget = None; } } @@ -663,7 +689,7 @@ impl Memory { /// Stop editing of active [`TextEdit`](crate::TextEdit) (if any). #[inline(always)] pub fn stop_text_input(&mut self) { - self.interaction.focus.id = None; + self.interaction.focus.focused_widget = None; } #[inline(always)] diff --git a/crates/egui/src/menu.rs b/crates/egui/src/menu.rs index 6c8d8f9b8..ab92f063d 100644 --- a/crates/egui/src/menu.rs +++ b/crates/egui/src/menu.rs @@ -111,7 +111,7 @@ pub fn menu_button( /// Returns `None` if the menu is not open. pub fn menu_image_button( ui: &mut Ui, - image_button: ImageButton, + image_button: ImageButton<'_>, add_contents: impl FnOnce(&mut Ui) -> R, ) -> InnerResponse> { stationary_menu_image_impl(ui, image_button, Box::new(add_contents)) @@ -201,7 +201,7 @@ fn stationary_menu_impl<'c, R>( /// Responds to primary clicks. fn stationary_menu_image_impl<'c, R>( ui: &mut Ui, - image_button: ImageButton, + image_button: ImageButton<'_>, add_contents: Box R + 'c>, ) -> InnerResponse> { let bar_id = ui.id(); diff --git a/crates/egui/src/painter.rs b/crates/egui/src/painter.rs index ab2b0c153..388a1ced1 100644 --- a/crates/egui/src/painter.rs +++ b/crates/egui/src/painter.rs @@ -350,6 +350,18 @@ impl Painter { /// unless you want to crop or flip the image. /// /// `tint` is a color multiplier. Use [`Color32::WHITE`] if you don't want to tint the image. + /// + /// Usually it is easier to use [`crate::Image::paint_at`] instead: + /// + /// ``` + /// # egui::__run_test_ui(|ui| { + /// # let rect = egui::Rect::from_min_size(Default::default(), egui::Vec2::splat(100.0)); + /// egui::Image::new(egui::include_image!("../assets/ferris.png")) + /// .rounding(5.0) + /// .tint(egui::Color32::LIGHT_BLUE) + /// .paint_at(ui, rect); + /// # }); + /// ``` pub fn image(&self, texture_id: epaint::TextureId, rect: Rect, uv: Rect, tint: Color32) { self.add(Shape::image(texture_id, rect, uv, tint)); } diff --git a/crates/egui/src/style.rs b/crates/egui/src/style.rs index 3c7da1b1d..cc31a1c61 100644 --- a/crates/egui/src/style.rs +++ b/crates/egui/src/style.rs @@ -2,7 +2,9 @@ #![allow(clippy::if_same_then_else)] -use crate::{ecolor::*, emath::*, FontFamily, FontId, Response, RichText, WidgetText}; +use crate::{ + ecolor::*, emath::*, ComboBox, CursorIcon, FontFamily, FontId, Response, RichText, WidgetText, +}; use epaint::{Rounding, Shadow, Stroke}; use std::collections::BTreeMap; @@ -544,6 +546,16 @@ pub struct Visuals { /// /// Enabling this will affect ALL sliders, and can be enabled/disabled per slider with [`Slider::trailing_fill`]. pub slider_trailing_fill: bool, + + /// Should the cursor change when the user hovers over an interactive/clickable item? + /// + /// This is consistent with a lot of browser-based applications (vscode, github + /// all turn your cursor into [`CursorIcon::PointingHand`] when a button is + /// hovered) but it is inconsistent with native UI toolkits. + pub interact_cursor: Option, + + /// Show a spinner when loading an image. + pub image_loading_spinners: bool, } impl Visuals { @@ -806,6 +818,10 @@ impl Visuals { striped: false, slider_trailing_fill: false, + + interact_cursor: None, + + image_loading_spinners: true, } } @@ -1376,6 +1392,9 @@ impl Visuals { striped, slider_trailing_fill, + interact_cursor, + + image_loading_spinners, } = self; ui.collapsing("Background Colors", |ui| { @@ -1441,6 +1460,19 @@ impl Visuals { ui.checkbox(slider_trailing_fill, "Add trailing color to sliders"); + ComboBox::from_label("Interact Cursor") + .selected_text(format!("{interact_cursor:?}")) + .show_ui(ui, |ui| { + ui.selectable_value(interact_cursor, None, "None"); + + for icon in CursorIcon::ALL { + ui.selectable_value(interact_cursor, Some(icon), format!("{icon:?}")); + } + }); + + ui.checkbox(image_loading_spinners, "Image loading spinners") + .on_hover_text("Show a spinner when an Image is loading"); + ui.vertical_centered(|ui| reset_button(ui, self)); } } diff --git a/crates/egui/src/ui.rs b/crates/egui/src/ui.rs index c40ef5f51..01241ccf2 100644 --- a/crates/egui/src/ui.rs +++ b/crates/egui/src/ui.rs @@ -1558,56 +1558,34 @@ impl Ui { response } - /// Show an image here with the given size. - /// - /// In order to display an image you must first acquire a [`TextureHandle`]. - /// This is best done with [`egui_extras::RetainedImage`](https://docs.rs/egui_extras/latest/egui_extras/image/struct.RetainedImage.html) or [`Context::load_texture`]. - /// - /// ``` - /// struct MyImage { - /// texture: Option, - /// } - /// - /// impl MyImage { - /// fn ui(&mut self, ui: &mut egui::Ui) { - /// let texture: &egui::TextureHandle = self.texture.get_or_insert_with(|| { - /// // Load the texture only once. - /// ui.ctx().load_texture( - /// "my-image", - /// egui::ColorImage::example(), - /// Default::default() - /// ) - /// }); - /// - /// // Show the image: - /// ui.image(texture, texture.size_vec2()); - /// } - /// } - /// ``` - /// - /// See also [`crate::Image`] and [`crate::ImageButton`]. - #[inline] - pub fn image(&mut self, texture_id: impl Into, size: impl Into) -> Response { - Image::new(texture_id, size).ui(self) - } - /// Show an image available at the given `uri`. /// /// ⚠ This will do nothing unless you install some image loaders first! - /// The easiest way to do this is via [`egui_extras::loaders::install`](https://docs.rs/egui_extras/latest/egui_extras/loaders/fn.install.html). + /// The easiest way to do this is via [`egui_extras::install_image_loaders`](https://docs.rs/egui_extras/latest/egui_extras/loaders/fn.install.html). /// /// The loaders handle caching image data, sampled textures, etc. across frames, so calling this is immediate-mode safe. /// /// ``` /// # egui::__run_test_ui(|ui| { - /// ui.image2("file://ferris.svg"); + /// ui.image("https://picsum.photos/480"); + /// ui.image("file://assets/ferris.png"); + /// ui.image(egui::include_image!("../assets/ferris.png")); + /// ui.add( + /// egui::Image::new(egui::include_image!("../assets/ferris.png")) + /// .rounding(egui::Rounding::same(6.0)) + /// ); /// # }); /// ``` /// - /// See also [`crate::Image2`] and [`crate::ImageSource`]. + /// Using [`include_image`] is often the most ergonomic, and the path + /// will be resolved at compile-time and embedded in the binary. + /// When using a "file://" url on the other hand, you need to make sure + /// the files can be found in the right spot at runtime! + /// + /// See also [`crate::Image`], [`crate::ImageSource`]. #[inline] - pub fn image2<'a>(&mut self, source: impl Into>) -> Response { - Image2::new(source.into()).ui(self) + pub fn image<'a>(&mut self, source: impl Into>) -> Response { + Image::new(source).ui(self) } } @@ -1710,7 +1688,7 @@ impl Ui { /// # }); /// ``` /// - /// Se also [`Self::scope`]. + /// See also [`Self::scope`]. pub fn group(&mut self, add_contents: impl FnOnce(&mut Ui) -> R) -> InnerResponse { crate::Frame::group(self.style()).show(self, add_contents) } @@ -2206,15 +2184,9 @@ impl Ui { /// If called from within a menu this will instead create a button for a sub-menu. /// /// ```ignore - /// use egui_extras; + /// let img = egui::include_image!("../assets/ferris.png"); /// - /// let img = egui_extras::RetainedImage::from_svg_bytes_with_size( - /// "rss", - /// include_bytes!("rss.svg"), - /// egui_extras::image::FitTo::Size(24, 24), - /// ); - /// - /// ui.menu_image_button(img.texture_id(ctx), img.size_vec2(), |ui| { + /// ui.menu_image_button(img, |ui| { /// ui.menu_button("My sub-menu", |ui| { /// if ui.button("Close the menu").clicked() { /// ui.close_menu(); @@ -2225,16 +2197,15 @@ impl Ui { /// /// See also: [`Self::close_menu`] and [`Response::context_menu`]. #[inline] - pub fn menu_image_button( + pub fn menu_image_button<'a, R>( &mut self, - texture_id: TextureId, - image_size: impl Into, + image: impl Into>, add_contents: impl FnOnce(&mut Ui) -> R, ) -> InnerResponse> { if let Some(menu_state) = self.menu_state.clone() { menu::submenu_button(self, menu_state, String::new(), add_contents) } else { - menu::menu_image_button(self, ImageButton::new(texture_id, image_size), add_contents) + menu::menu_image_button(self, ImageButton::new(image), add_contents) } } } diff --git a/crates/egui/src/widget_text.rs b/crates/egui/src/widget_text.rs index 531f35820..fe0de370c 100644 --- a/crates/egui/src/widget_text.rs +++ b/crates/egui/src/widget_text.rs @@ -269,12 +269,66 @@ impl RichText { fonts.row_height(&font_id) } + /// Append to an existing [`LayoutJob`] + /// + /// Note that the color of the [`RichText`] must be set, or may default to an undesirable color. + /// + /// ### Example + /// ``` + /// use egui::{Style, RichText, text::LayoutJob, Color32, FontSelection, Align}; + /// + /// let style = Style::default(); + /// let mut layout_job = LayoutJob::default(); + /// RichText::new("Normal") + /// .color(style.visuals.text_color()) + /// .append_to( + /// &mut layout_job, + /// &style, + /// FontSelection::Default, + /// Align::Center, + /// ); + /// RichText::new("Large and underlined") + /// .color(style.visuals.text_color()) + /// .size(20.0) + /// .underline() + /// .append_to( + /// &mut layout_job, + /// &style, + /// FontSelection::Default, + /// Align::Center, + /// ); + /// ``` + pub fn append_to( + self, + layout_job: &mut LayoutJob, + style: &Style, + fallback_font: FontSelection, + default_valign: Align, + ) { + let (text, format) = self.into_text_and_format(style, fallback_font, default_valign); + + layout_job.append(&text, 0.0, format); + } + fn into_text_job( self, style: &Style, fallback_font: FontSelection, default_valign: Align, ) -> WidgetTextJob { + let job_has_color = self.get_text_color(&style.visuals).is_some(); + let (text, text_format) = self.into_text_and_format(style, fallback_font, default_valign); + + let job = LayoutJob::single_section(text, text_format); + WidgetTextJob { job, job_has_color } + } + + fn into_text_and_format( + self, + style: &Style, + fallback_font: FontSelection, + default_valign: Align, + ) -> (String, crate::text::TextFormat) { let text_color = self.get_text_color(&style.visuals); let Self { @@ -295,7 +349,6 @@ impl RichText { raised, } = self; - let job_has_color = text_color.is_some(); let line_color = text_color.unwrap_or_else(|| style.visuals.text_color()); let text_color = text_color.unwrap_or(crate::Color32::TEMPORARY_COLOR); @@ -336,20 +389,20 @@ impl RichText { default_valign }; - let text_format = crate::text::TextFormat { - font_id, - extra_letter_spacing, - line_height, - color: text_color, - background: background_color, - italics, - underline, - strikethrough, - valign, - }; - - let job = LayoutJob::single_section(text, text_format); - WidgetTextJob { job, job_has_color } + ( + text, + crate::text::TextFormat { + font_id, + extra_letter_spacing, + line_height, + color: text_color, + background: background_color, + italics, + underline, + strikethrough, + valign, + }, + ) } fn get_text_color(&self, visuals: &Visuals) -> Option { diff --git a/crates/egui/src/widgets/button.rs b/crates/egui/src/widgets/button.rs index 932918ab6..4c94cc9d4 100644 --- a/crates/egui/src/widgets/button.rs +++ b/crates/egui/src/widgets/button.rs @@ -19,8 +19,9 @@ use crate::*; /// # }); /// ``` #[must_use = "You should put this widget in an ui with `ui.add(widget);`"] -pub struct Button { - text: WidgetText, +pub struct Button<'a> { + image: Option>, + text: Option, shortcut_text: WidgetText, wrap: Option, @@ -32,13 +33,30 @@ pub struct Button { frame: Option, min_size: Vec2, rounding: Option, - image: Option, + selected: bool, } -impl Button { +impl<'a> Button<'a> { pub fn new(text: impl Into) -> Self { + Self::opt_image_and_text(None, Some(text.into())) + } + + /// Creates a button with an image. The size of the image as displayed is defined by the provided size. + #[allow(clippy::needless_pass_by_value)] + pub fn image(image: impl Into>) -> Self { + Self::opt_image_and_text(Some(image.into()), None) + } + + /// Creates a button with an image to the left of the text. The size of the image as displayed is defined by the provided size. + #[allow(clippy::needless_pass_by_value)] + pub fn image_and_text(image: impl Into>, text: impl Into) -> Self { + Self::opt_image_and_text(Some(image.into()), Some(text.into())) + } + + pub fn opt_image_and_text(image: Option>, text: Option) -> Self { Self { - text: text.into(), + text, + image, shortcut_text: Default::default(), wrap: None, fill: None, @@ -48,20 +66,7 @@ impl Button { frame: None, min_size: Vec2::ZERO, rounding: None, - image: None, - } - } - - /// Creates a button with an image to the left of the text. The size of the image as displayed is defined by the provided size. - #[allow(clippy::needless_pass_by_value)] - pub fn image_and_text( - texture_id: TextureId, - image_size: impl Into, - text: impl Into, - ) -> Self { - Self { - image: Some(widgets::Image::new(texture_id, image_size)), - ..Self::new(text) + selected: false, } } @@ -96,7 +101,9 @@ impl Button { /// Make this a small button, suitable for embedding into text. pub fn small(mut self) -> Self { - self.text = self.text.text_style(TextStyle::Body); + if let Some(text) = self.text { + self.text = Some(text.text_style(TextStyle::Body)); + } self.small = true; self } @@ -135,12 +142,19 @@ impl Button { self.shortcut_text = shortcut_text.into(); self } + + /// If `true`, mark this button as "selected". + pub fn selected(mut self, selected: bool) -> Self { + self.selected = selected; + self + } } -impl Widget for Button { +impl Widget for Button<'_> { fn ui(self, ui: &mut Ui) -> Response { let Button { text, + image, shortcut_text, wrap, fill, @@ -150,32 +164,58 @@ impl Widget for Button { frame, min_size, rounding, - image, + selected, } = self; let frame = frame.unwrap_or_else(|| ui.visuals().button_frame); - let mut button_padding = ui.spacing().button_padding; + let mut button_padding = if frame { + ui.spacing().button_padding + } else { + Vec2::ZERO + }; if small { button_padding.y = 0.0; } + let space_available_for_image = if let Some(text) = &text { + let font_height = ui.fonts(|fonts| text.font_height(fonts, ui.style())); + Vec2::splat(font_height) // Reasonable? + } else { + ui.available_size() - 2.0 * button_padding + }; + + let image_size = if let Some(image) = &image { + image + .load_and_calc_size(ui, space_available_for_image) + .unwrap_or(space_available_for_image) + } else { + Vec2::ZERO + }; + let mut text_wrap_width = ui.available_width() - 2.0 * button_padding.x; - if let Some(image) = image { - text_wrap_width -= image.size().x + ui.spacing().icon_spacing; + if image.is_some() { + text_wrap_width -= image_size.x + ui.spacing().icon_spacing; } if !shortcut_text.is_empty() { text_wrap_width -= 60.0; // Some space for the shortcut text (which we never wrap). } - let text = text.into_galley(ui, wrap, text_wrap_width, TextStyle::Button); + let text = text.map(|text| text.into_galley(ui, wrap, text_wrap_width, TextStyle::Button)); let shortcut_text = (!shortcut_text.is_empty()) .then(|| shortcut_text.into_galley(ui, Some(false), f32::INFINITY, TextStyle::Button)); - let mut desired_size = text.size(); - if let Some(image) = image { - desired_size.x += image.size().x + ui.spacing().icon_spacing; - desired_size.y = desired_size.y.max(image.size().y); + let mut desired_size = Vec2::ZERO; + if image.is_some() { + desired_size.x += image_size.x; + desired_size.y = desired_size.y.max(image_size.y); + } + if image.is_some() && text.is_some() { + desired_size.x += ui.spacing().icon_spacing; + } + if let Some(text) = &text { + desired_size.x += text.size().x; + desired_size.y = desired_size.y.max(text.size().y); } if let Some(shortcut_text) = &shortcut_text { desired_size.x += ui.spacing().item_spacing.x + shortcut_text.size().x; @@ -187,32 +227,82 @@ impl Widget for Button { } desired_size = desired_size.at_least(min_size); - let (rect, response) = ui.allocate_at_least(desired_size, sense); - response.widget_info(|| WidgetInfo::labeled(WidgetType::Button, text.text())); + let (rect, mut response) = ui.allocate_at_least(desired_size, sense); + response.widget_info(|| { + if let Some(text) = &text { + WidgetInfo::labeled(WidgetType::Button, text.text()) + } else { + WidgetInfo::new(WidgetType::Button) + } + }); if ui.is_rect_visible(rect) { let visuals = ui.style().interact(&response); - if frame { - let fill = fill.unwrap_or(visuals.weak_bg_fill); - let stroke = stroke.unwrap_or(visuals.bg_stroke); - let rounding = rounding.unwrap_or(visuals.rounding); - ui.painter() - .rect(rect.expand(visuals.expansion), rounding, fill, stroke); - } - - let text_pos = if let Some(image) = image { - let icon_spacing = ui.spacing().icon_spacing; - pos2( - rect.min.x + button_padding.x + image.size().x + icon_spacing, - rect.center().y - 0.5 * text.size().y, + let (frame_expansion, frame_rounding, frame_fill, frame_stroke) = if selected { + let selection = ui.visuals().selection; + ( + Vec2::ZERO, + Rounding::ZERO, + selection.bg_fill, + selection.stroke, + ) + } else if frame { + let expansion = Vec2::splat(visuals.expansion); + ( + expansion, + visuals.rounding, + visuals.weak_bg_fill, + visuals.bg_stroke, ) } else { - ui.layout() - .align_size_within_rect(text.size(), rect.shrink2(button_padding)) - .min + Default::default() }; - text.paint_with_visuals(ui.painter(), text_pos, visuals); + let frame_rounding = rounding.unwrap_or(frame_rounding); + let frame_fill = fill.unwrap_or(frame_fill); + let frame_stroke = stroke.unwrap_or(frame_stroke); + ui.painter().rect( + rect.expand2(frame_expansion), + frame_rounding, + frame_fill, + frame_stroke, + ); + + let mut cursor_x = rect.min.x + button_padding.x; + + if let Some(image) = &image { + let image_rect = Rect::from_min_size( + pos2(cursor_x, rect.center().y - 0.5 - (image_size.y / 2.0)), + image_size, + ); + cursor_x += image_size.x; + let tlr = image.load_for_size(ui.ctx(), image_size); + widgets::image::paint_texture_load_result( + ui, + &tlr, + image_rect, + image.show_loading_spinner, + image.image_options(), + ); + response = + widgets::image::texture_load_result_response(image.source(), &tlr, response); + } + + if image.is_some() && text.is_some() { + cursor_x += ui.spacing().icon_spacing; + } + + if let Some(text) = text { + let text_pos = if image.is_some() || shortcut_text.is_some() { + pos2(cursor_x, rect.center().y - 0.5 * text.size().y) + } else { + // Make sure button text is centered if within a centered layout + ui.layout() + .align_size_within_rect(text.size(), rect.shrink2(button_padding)) + .min + }; + text.paint_with_visuals(ui.painter(), text_pos, visuals); + } if let Some(shortcut_text) = shortcut_text { let shortcut_text_pos = pos2( @@ -225,16 +315,11 @@ impl Widget for Button { ui.visuals().weak_text_color(), ); } + } - if let Some(image) = image { - let image_rect = Rect::from_min_size( - pos2( - rect.min.x + button_padding.x, - rect.center().y - 0.5 - (image.size().y / 2.0), - ), - image.size(), - ); - image.paint_at(ui, image_rect); + if let Some(cursor) = ui.visuals().interact_cursor { + if response.hovered { + ui.ctx().set_cursor_icon(cursor); } } @@ -462,17 +547,17 @@ impl Widget for RadioButton { /// A clickable image within a frame. #[must_use = "You should put this widget in an ui with `ui.add(widget);`"] #[derive(Clone, Debug)] -pub struct ImageButton { - image: widgets::Image, +pub struct ImageButton<'a> { + image: Image<'a>, sense: Sense, frame: bool, selected: bool, } -impl ImageButton { - pub fn new(texture_id: impl Into, size: impl Into) -> Self { +impl<'a> ImageButton<'a> { + pub fn new(image: impl Into>) -> Self { Self { - image: widgets::Image::new(texture_id, size), + image: image.into(), sense: Sense::click(), frame: true, selected: false, @@ -511,27 +596,28 @@ impl ImageButton { } } -impl Widget for ImageButton { +impl<'a> Widget for ImageButton<'a> { fn ui(self, ui: &mut Ui) -> Response { - let Self { - image, - sense, - frame, - selected, - } = self; - - let padding = if frame { + let padding = if self.frame { // so we can see that it is a button: Vec2::splat(ui.spacing().button_padding.x) } else { Vec2::ZERO }; - let padded_size = image.size() + 2.0 * padding; - let (rect, response) = ui.allocate_exact_size(padded_size, sense); + + let available_size_for_image = ui.available_size() - 2.0 * padding; + let tlr = self.image.load_for_size(ui.ctx(), available_size_for_image); + let original_image_size = tlr.as_ref().ok().and_then(|t| t.size()); + let image_size = self + .image + .calc_size(available_size_for_image, original_image_size); + + 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)); if ui.is_rect_visible(rect) { - let (expansion, rounding, fill, stroke) = if selected { + let (expansion, rounding, fill, stroke) = if self.selected { let selection = ui.visuals().selection; ( Vec2::ZERO, @@ -539,7 +625,7 @@ impl Widget for ImageButton { selection.bg_fill, selection.stroke, ) - } else if frame { + } else if self.frame { let visuals = ui.style().interact(&response); let expansion = Vec2::splat(visuals.expansion); ( @@ -552,23 +638,25 @@ impl Widget for ImageButton { Default::default() }; - let image = image.rounding(rounding); // apply rounding to the image - // Draw frame background (for transparent images): ui.painter() .rect_filled(rect.expand2(expansion), rounding, fill); let image_rect = ui .layout() - .align_size_within_rect(image.size(), rect.shrink2(padding)); + .align_size_within_rect(image_size, rect.shrink2(padding)); // let image_rect = image_rect.expand2(expansion); // can make it blurry, so let's not - image.paint_at(ui, image_rect); + let image_options = ImageOptions { + rounding, // apply rounding to the image + ..self.image.image_options().clone() + }; + widgets::image::paint_texture_load_result(ui, &tlr, image_rect, None, &image_options); // Draw frame outline: ui.painter() .rect_stroke(rect.expand2(expansion), rounding, stroke); } - response + widgets::image::texture_load_result_response(self.image.source(), &tlr, response) } } diff --git a/crates/egui/src/widgets/image.rs b/crates/egui/src/widgets/image.rs index 496ddaaf9..fde5c3304 100644 --- a/crates/egui/src/widgets/image.rs +++ b/crates/egui/src/widgets/image.rs @@ -1,90 +1,213 @@ -use std::sync::Arc; +use std::borrow::Cow; -use crate::load::Bytes; -use crate::{load::SizeHint, load::TexturePoll, *}; +use crate::load::TextureLoadResult; +use crate::{ + load::{Bytes, SizeHint, SizedTexture, TexturePoll}, + *, +}; use emath::Rot2; +use epaint::{util::FloatOrd, RectShape}; -/// An widget to show an image of a given size. +/// A widget which displays an image. /// -/// In order to display an image you must first acquire a [`TextureHandle`]. -/// This is best done with [`egui_extras::RetainedImage`](https://docs.rs/egui_extras/latest/egui_extras/image/struct.RetainedImage.html) or [`Context::load_texture`]. +/// The task of actually loading the image is deferred to when the `Image` is added to the [`Ui`], +/// and how it is loaded depends on the provided [`ImageSource`]: /// +/// - [`ImageSource::Uri`] will load the image using the [asynchronous loading process][`load`]. +/// - [`ImageSource::Bytes`] will also load the image using the [asynchronous loading process][`load`], but with lower latency. +/// - [`ImageSource::Texture`] will use the provided texture. +/// +/// See [`load`] for more information. +/// +/// ### Examples +/// // Using it in a layout: /// ``` -/// struct MyImage { -/// texture: Option, -/// } -/// -/// impl MyImage { -/// fn ui(&mut self, ui: &mut egui::Ui) { -/// let texture: &egui::TextureHandle = self.texture.get_or_insert_with(|| { -/// // Load the texture only once. -/// ui.ctx().load_texture( -/// "my-image", -/// egui::ColorImage::example(), -/// Default::default() -/// ) -/// }); -/// -/// // Show the image: -/// ui.add(egui::Image::new(texture, texture.size_vec2())); -/// -/// // Shorter version: -/// ui.image(texture, texture.size_vec2()); -/// } -/// } +/// # egui::__run_test_ui(|ui| { +/// ui.add( +/// egui::Image::new(egui::include_image!("../../assets/ferris.png")) +/// .rounding(5.0) +/// ); +/// # }); /// ``` /// -/// Se also [`crate::Ui::image`] and [`crate::ImageButton`]. +/// // Using it just to paint: +/// ``` +/// # egui::__run_test_ui(|ui| { +/// # let rect = egui::Rect::from_min_size(Default::default(), egui::Vec2::splat(100.0)); +/// egui::Image::new(egui::include_image!("../../assets/ferris.png")) +/// .rounding(5.0) +/// .tint(egui::Color32::LIGHT_BLUE) +/// .paint_at(ui, rect); +/// # }); +/// ``` #[must_use = "You should put this widget in an ui with `ui.add(widget);`"] -#[derive(Clone, Copy, Debug)] -pub struct Image { - texture_id: TextureId, - uv: Rect, - size: Vec2, - bg_fill: Color32, - tint: Color32, +#[derive(Debug, Clone)] +pub struct Image<'a> { + source: ImageSource<'a>, + texture_options: TextureOptions, + image_options: ImageOptions, sense: Sense, - rotation: Option<(Rot2, Vec2)>, - rounding: Rounding, + size: ImageSize, + pub(crate) show_loading_spinner: Option, } -impl Image { - pub fn new(texture_id: impl Into, size: impl Into) -> Self { - Self { - texture_id: texture_id.into(), - uv: Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), - size: size.into(), - bg_fill: Default::default(), - tint: Color32::WHITE, - sense: Sense::hover(), - rotation: None, - rounding: Rounding::ZERO, +impl<'a> Image<'a> { + /// Load the image from some source. + pub fn new(source: impl Into>) -> Self { + fn new_mono(source: ImageSource<'_>) -> Image<'_> { + let size = if let ImageSource::Texture(tex) = &source { + // User is probably expecting their texture to have + // the exact size of the provided `SizedTexture`. + ImageSize { + maintain_aspect_ratio: true, + max_size: Vec2::INFINITY, + fit: ImageFit::Exact(tex.size), + } + } else { + Default::default() + }; + + Image { + source, + texture_options: Default::default(), + image_options: Default::default(), + sense: Sense::hover(), + size, + show_loading_spinner: None, + } } + + new_mono(source.into()) + } + + /// Load the image from a URI. + /// + /// See [`ImageSource::Uri`]. + pub fn from_uri(uri: impl Into>) -> Self { + Self::new(ImageSource::Uri(uri.into())) + } + + /// Load the image from an existing texture. + /// + /// See [`ImageSource::Texture`]. + pub fn from_texture(texture: impl Into) -> Self { + Self::new(ImageSource::Texture(texture.into())) + } + + /// Load the image from some raw bytes. + /// + /// For better error messages, use the `bytes://` prefix for the URI. + /// + /// See [`ImageSource::Bytes`]. + pub fn from_bytes(uri: impl Into>, bytes: impl Into) -> Self { + Self::new(ImageSource::Bytes(uri.into(), bytes.into())) + } + + /// Texture options used when creating the texture. + #[inline] + pub fn texture_options(mut self, texture_options: TextureOptions) -> Self { + self.texture_options = texture_options; + self + } + + /// Set the max width of the image. + /// + /// No matter what the image is scaled to, it will never exceed this limit. + #[inline] + pub fn max_width(mut self, width: f32) -> Self { + self.size.max_size.x = width; + self + } + + /// Set the max height of the image. + /// + /// No matter what the image is scaled to, it will never exceed this limit. + #[inline] + pub fn max_height(mut self, height: f32) -> Self { + self.size.max_size.y = height; + self + } + + /// Set the max size of the image. + /// + /// No matter what the image is scaled to, it will never exceed this limit. + #[inline] + pub fn max_size(mut self, size: Vec2) -> Self { + self.size.max_size = size; + self + } + + /// Whether or not the [`ImageFit`] should maintain the image's original aspect ratio. + #[inline] + pub fn maintain_aspect_ratio(mut self, value: bool) -> Self { + self.size.maintain_aspect_ratio = value; + self + } + + /// Fit the image to its original size with some scaling. + /// + /// This will cause the image to overflow if it is larger than the available space. + /// + /// If [`Image::max_size`] is set, this is guaranteed to never exceed that limit. + #[inline] + pub fn fit_to_original_size(mut self, scale: f32) -> Self { + self.size.fit = ImageFit::Original { scale }; + self + } + + /// Fit the image to an exact size. + /// + /// If [`Image::max_size`] is set, this is guaranteed to never exceed that limit. + #[inline] + pub fn fit_to_exact_size(mut self, size: Vec2) -> Self { + self.size.fit = ImageFit::Exact(size); + self + } + + /// Fit the image to a fraction of the available space. + /// + /// If [`Image::max_size`] is set, this is guaranteed to never exceed that limit. + #[inline] + pub fn fit_to_fraction(mut self, fraction: Vec2) -> Self { + self.size.fit = ImageFit::Fraction(fraction); + self + } + + /// Fit the image to 100% of its available size, shrinking it if necessary. + /// + /// This is a shorthand for [`Image::fit_to_fraction`] with `1.0` for both width and height. + /// + /// If [`Image::max_size`] is set, this is guaranteed to never exceed that limit. + #[inline] + pub fn shrink_to_fit(self) -> Self { + self.fit_to_fraction(Vec2::new(1.0, 1.0)) + } + + /// Make the image respond to clicks and/or drags. + #[inline] + pub fn sense(mut self, sense: Sense) -> Self { + self.sense = sense; + self } /// Select UV range. Default is (0,0) in top-left, (1,1) bottom right. + #[inline] pub fn uv(mut self, uv: impl Into) -> Self { - self.uv = uv.into(); + self.image_options.uv = uv.into(); self } /// A solid color to put behind the image. Useful for transparent images. + #[inline] pub fn bg_fill(mut self, bg_fill: impl Into) -> Self { - self.bg_fill = bg_fill.into(); + self.image_options.bg_fill = bg_fill.into(); self } /// Multiply image color with this. Default is WHITE (no tint). + #[inline] pub fn tint(mut self, tint: impl Into) -> Self { - self.tint = tint.into(); - self - } - - /// Make the image respond to clicks and/or drags. - /// - /// Consider using [`ImageButton`] instead, for an on-hover effect. - pub fn sense(mut self, sense: Sense) -> Self { - self.sense = sense; + self.image_options.tint = tint.into(); self } @@ -97,9 +220,10 @@ impl Image { /// /// Due to limitations in the current implementation, /// this will turn off rounding of the image. + #[inline] pub fn rotate(mut self, angle: f32, origin: Vec2) -> Self { - self.rotation = Some((Rot2::from_angle(angle), origin)); - self.rounding = Rounding::ZERO; // incompatible with rotation + self.image_options.rotation = Some((Rot2::from_angle(angle), origin)); + self.image_options.rounding = Rounding::ZERO; // incompatible with rotation self } @@ -109,161 +233,410 @@ impl Image { /// /// Due to limitations in the current implementation, /// this will turn off any rotation of the image. + #[inline] pub fn rounding(mut self, rounding: impl Into) -> Self { - self.rounding = rounding.into(); - if self.rounding != Rounding::ZERO { - self.rotation = None; // incompatible with rounding + self.image_options.rounding = rounding.into(); + if self.image_options.rounding != Rounding::ZERO { + self.image_options.rotation = None; // incompatible with rounding } self } + + /// Show a spinner when the image is loading. + /// + /// By default this uses the value of [`Visuals::image_loading_spinners`]. + #[inline] + pub fn show_loading_spinner(mut self, show: bool) -> Self { + self.show_loading_spinner = Some(show); + self + } } -impl Image { - pub fn size(&self) -> Vec2 { - self.size +impl<'a, T: Into>> From for Image<'a> { + fn from(value: T) -> Self { + Image::new(value) + } +} + +impl<'a> Image<'a> { + /// Returns the size the image will occupy in the final UI. + #[inline] + pub fn calc_size(&self, available_size: Vec2, original_image_size: Option) -> Vec2 { + let original_image_size = original_image_size.unwrap_or(Vec2::splat(24.0)); // Fallback for still-loading textures, or failure to load. + self.size.calc_size(available_size, original_image_size) } - pub fn paint_at(&self, ui: &mut Ui, rect: Rect) { - if ui.is_rect_visible(rect) { - use epaint::*; - let Self { - texture_id, - uv, - size, - bg_fill, - tint, - sense: _, - rotation, - rounding, - } = self; + pub fn load_and_calc_size(&self, ui: &mut Ui, available_size: Vec2) -> Option { + let image_size = self.load_for_size(ui.ctx(), available_size).ok()?.size()?; + Some(self.size.calc_size(available_size, image_size)) + } - if *bg_fill != Default::default() { - let mut mesh = Mesh::default(); - mesh.add_colored_rect(rect, *bg_fill); - ui.painter().add(Shape::mesh(mesh)); - } - - if let Some((rot, origin)) = rotation { - // TODO(emilk): implement this using `PathShape` (add texture support to it). - // This will also give us anti-aliasing of rotated images. - egui_assert!( - *rounding == Rounding::ZERO, - "Image had both rounding and rotation. Please pick only one" - ); - - let mut mesh = Mesh::with_texture(*texture_id); - mesh.add_rect_with_uv(rect, *uv, *tint); - mesh.rotate(*rot, rect.min + *origin * *size); - ui.painter().add(Shape::mesh(mesh)); - } else { - ui.painter().add(RectShape { - rect, - rounding: *rounding, - fill: *tint, - stroke: Stroke::NONE, - fill_texture_id: *texture_id, - uv: *uv, - }); - } + #[inline] + pub fn size(&self) -> Option { + match &self.source { + ImageSource::Texture(texture) => Some(texture.size), + ImageSource::Uri(_) | ImageSource::Bytes(_, _) => None, } } -} -impl Widget for Image { - fn ui(self, ui: &mut Ui) -> Response { - let (rect, response) = ui.allocate_exact_size(self.size, self.sense); - self.paint_at(ui, rect); - response + #[inline] + pub fn image_options(&self) -> &ImageOptions { + &self.image_options + } + + #[inline] + pub fn source(&self) -> &ImageSource<'a> { + &self.source + } + + /// Load the image from its [`Image::source`], returning the resulting [`SizedTexture`]. + /// + /// The `available_size` is used as a hint when e.g. rendering an svg. + /// + /// # Errors + /// May fail if they underlying [`Context::try_load_texture`] call fails. + pub fn load_for_size(&self, ctx: &Context, available_size: Vec2) -> TextureLoadResult { + let size_hint = self.size.hint(available_size); + self.source + .clone() + .load(ctx, self.texture_options, size_hint) + } + + /// Paint the image in the given rectangle. + /// + /// ``` + /// # egui::__run_test_ui(|ui| { + /// # let rect = egui::Rect::from_min_size(Default::default(), egui::Vec2::splat(100.0)); + /// egui::Image::new(egui::include_image!("../../assets/ferris.png")) + /// .rounding(5.0) + /// .tint(egui::Color32::LIGHT_BLUE) + /// .paint_at(ui, rect); + /// # }); + /// ``` + #[inline] + pub fn paint_at(&self, ui: &mut Ui, rect: Rect) { + paint_texture_load_result( + ui, + &self.load_for_size(ui.ctx(), rect.size()), + rect, + self.show_loading_spinner, + &self.image_options, + ); } } -/// A widget which displays an image. -/// -/// There are three ways to construct this widget: -/// - [`Image2::from_uri`] -/// - [`Image2::from_bytes`] -/// - [`Image2::from_static_bytes`] -/// -/// In both cases the task of actually loading the image -/// is deferred to when the `Image2` is added to the [`Ui`]. -/// -/// See [`crate::load`] for more information. -pub struct Image2<'a> { - source: ImageSource<'a>, - texture_options: TextureOptions, - size_hint: SizeHint, - fit: ImageFit, - sense: Sense, +impl<'a> Widget for Image<'a> { + fn ui(self, ui: &mut Ui) -> Response { + let tlr = self.load_for_size(ui.ctx(), ui.available_size()); + let original_image_size = tlr.as_ref().ok().and_then(|t| t.size()); + let ui_size = self.calc_size(ui.available_size(), original_image_size); + + let (rect, response) = ui.allocate_exact_size(ui_size, self.sense); + if ui.is_rect_visible(rect) { + paint_texture_load_result( + ui, + &tlr, + rect, + self.show_loading_spinner, + &self.image_options, + ); + } + texture_load_result_response(&self.source, &tlr, response) + } } -#[derive(Default, Clone, Copy)] -enum ImageFit { - // TODO: options for aspect ratio - // TODO: other fit strategies - // FitToWidth, - // FitToHeight, - // FitToWidthExact(f32), - // FitToHeightExact(f32), - #[default] - ShrinkToFit, +/// This type determines the constraints on how +/// the size of an image should be calculated. +#[derive(Debug, Clone, Copy)] +pub struct ImageSize { + /// Whether or not the final size should maintain the original aspect ratio. + /// + /// This setting is applied last. + /// + /// This defaults to `true`. + pub maintain_aspect_ratio: bool, + + /// Determines the maximum size of the image. + /// + /// Defaults to `Vec2::INFINITY` (no limit). + pub max_size: Vec2, + + /// Determines how the image should shrink/expand/stretch/etc. to fit within its allocated space. + /// + /// This setting is applied first. + /// + /// Defaults to `ImageFit::Fraction([1, 1])` + pub fit: ImageFit, +} + +/// This type determines how the image should try to fit within the UI. +/// +/// The final fit will be clamped to [`ImageSize::max_size`]. +#[derive(Debug, Clone, Copy)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub enum ImageFit { + /// Fit the image to its original size, scaled by some factor. + /// + /// Ignores how much space is actually available in the ui. + Original { scale: f32 }, + + /// Fit the image to a fraction of the available size. + Fraction(Vec2), + + /// Fit the image to an exact size. + /// + /// Ignores how much space is actually available in the ui. + Exact(Vec2), } impl ImageFit { - pub fn calculate_final_size(&self, available_size: Vec2, image_size: Vec2) -> Vec2 { - let aspect_ratio = image_size.x / image_size.y; - // TODO: more image sizing options + pub fn resolve(self, available_size: Vec2, image_size: Vec2) -> Vec2 { match self { - // ImageFit::FitToWidth => todo!(), - // ImageFit::FitToHeight => todo!(), - // ImageFit::FitToWidthExact(_) => todo!(), - // ImageFit::FitToHeightExact(_) => todo!(), - ImageFit::ShrinkToFit => { - let width = if available_size.x < image_size.x { - available_size.x + ImageFit::Original { scale } => image_size * scale, + ImageFit::Fraction(fract) => available_size * fract, + ImageFit::Exact(size) => size, + } + } +} + +impl ImageSize { + /// Size hint for e.g. rasterizing an svg. + pub fn hint(&self, available_size: Vec2) -> SizeHint { + let size = match self.fit { + ImageFit::Original { scale } => return SizeHint::Scale(scale.ord()), + ImageFit::Fraction(fract) => available_size * fract, + ImageFit::Exact(size) => size, + }; + + let size = size.min(self.max_size); + + // TODO(emilk): take pixels_per_point into account here! + + // `inf` on an axis means "any value" + match (size.x.is_finite(), size.y.is_finite()) { + (true, true) => SizeHint::Size(size.x.round() as u32, size.y.round() as u32), + (true, false) => SizeHint::Width(size.x.round() as u32), + (false, true) => SizeHint::Height(size.y.round() as u32), + (false, false) => SizeHint::Scale(1.0.ord()), + } + } + + /// Calculate the final on-screen size in points. + pub fn calc_size(&self, available_size: Vec2, original_image_size: Vec2) -> Vec2 { + let Self { + maintain_aspect_ratio, + max_size, + fit, + } = *self; + match fit { + ImageFit::Original { scale } => { + let image_size = original_image_size * scale; + if image_size.x <= max_size.x && image_size.y <= max_size.y { + image_size } else { - image_size.x - }; - let height = if available_size.y < image_size.y { - available_size.y - } else { - image_size.y - }; - if width < height { - Vec2::new(width, width / aspect_ratio) - } else { - Vec2::new(height * aspect_ratio, height) + scale_to_fit(image_size, max_size, maintain_aspect_ratio) } } + ImageFit::Fraction(fract) => { + let scale_to_size = (available_size * fract).min(max_size); + scale_to_fit(original_image_size, scale_to_size, maintain_aspect_ratio) + } + ImageFit::Exact(size) => { + let scale_to_size = size.min(max_size); + scale_to_fit(original_image_size, scale_to_size, maintain_aspect_ratio) + } } } } -/// This type tells the [`Ui`] how to load the image. +// TODO: unit-tests +fn scale_to_fit(image_size: Vec2, available_size: Vec2, maintain_aspect_ratio: bool) -> Vec2 { + if maintain_aspect_ratio { + let ratio_x = available_size.x / image_size.x; + let ratio_y = available_size.y / image_size.y; + let ratio = if ratio_x < ratio_y { ratio_x } else { ratio_y }; + let ratio = if ratio.is_finite() { ratio } else { 1.0 }; + image_size * ratio + } else { + available_size + } +} + +impl Default for ImageSize { + #[inline] + fn default() -> Self { + Self { + max_size: Vec2::INFINITY, + fit: ImageFit::Fraction(Vec2::new(1.0, 1.0)), + maintain_aspect_ratio: true, + } + } +} + +/// This type tells the [`Ui`] how to load an image. +/// +/// This is used by [`Image::new`] and [`Ui::image`]. +#[derive(Clone)] pub enum ImageSource<'a> { /// Load the image from a URI. /// - /// This could be a `file://` url, `http://` url, or a `bare` identifier. + /// This could be a `file://` url, `http(s)?://` url, or a `bare` identifier. /// How the URI will be turned into a texture for rendering purposes is /// up to the registered loaders to handle. /// /// See [`crate::load`] for more information. - Uri(&'a str), + Uri(Cow<'a, str>), + + /// Load the image from an existing texture. + /// + /// The user is responsible for loading the texture, determining its size, + /// and allocating a [`TextureId`] for it. + Texture(SizedTexture), /// Load the image from some raw bytes. /// + /// For better error messages, use the `bytes://` prefix for the URI. + /// /// The [`Bytes`] may be: /// - `'static`, obtained from `include_bytes!` or similar /// - Anything that can be converted to `Arc<[u8]>` /// /// This instructs the [`Ui`] to cache the raw bytes, which are then further processed by any registered loaders. /// + /// See also [`include_image`] for an easy way to load and display static images. + /// /// See [`crate::load`] for more information. - Bytes(&'static str, Bytes), + Bytes(Cow<'static, str>, Bytes), +} + +impl<'a> std::fmt::Debug for ImageSource<'a> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ImageSource::Bytes(uri, _) | ImageSource::Uri(uri) => uri.as_ref().fmt(f), + ImageSource::Texture(st) => st.id.fmt(f), + } + } +} + +impl<'a> ImageSource<'a> { + /// Size of the texture, if known. + #[inline] + pub fn texture_size(&self) -> Option { + match self { + ImageSource::Texture(texture) => Some(texture.size), + ImageSource::Uri(_) | ImageSource::Bytes(_, _) => None, + } + } + + /// # Errors + /// Failure to load the texture. + pub fn load( + self, + ctx: &Context, + texture_options: TextureOptions, + size_hint: SizeHint, + ) -> TextureLoadResult { + match self { + Self::Texture(texture) => Ok(TexturePoll::Ready { texture }), + Self::Uri(uri) => ctx.try_load_texture(uri.as_ref(), texture_options, size_hint), + Self::Bytes(uri, bytes) => { + ctx.include_bytes(uri.clone(), bytes); + ctx.try_load_texture(uri.as_ref(), texture_options, size_hint) + } + } + } + + /// Get the `uri` that this image was constructed from. + /// + /// This will return `None` for [`Self::Texture`]. + pub fn uri(&self) -> Option<&str> { + match self { + ImageSource::Bytes(uri, _) | ImageSource::Uri(uri) => Some(uri), + ImageSource::Texture(_) => None, + } + } +} + +pub fn paint_texture_load_result( + ui: &Ui, + tlr: &TextureLoadResult, + rect: Rect, + show_loading_spinner: Option, + options: &ImageOptions, +) { + match tlr { + Ok(TexturePoll::Ready { texture }) => { + paint_texture_at(ui.painter(), rect, options, texture); + } + Ok(TexturePoll::Pending { .. }) => { + let show_loading_spinner = + show_loading_spinner.unwrap_or(ui.visuals().image_loading_spinners); + if show_loading_spinner { + Spinner::new().paint_at(ui, rect); + } + } + Err(_) => { + let font_id = TextStyle::Body.resolve(ui.style()); + ui.painter().text( + rect.center(), + Align2::CENTER_CENTER, + "⚠", + font_id, + ui.visuals().error_fg_color, + ); + } + } +} + +/// Attach tooltips like "Loading…" or "Failed loading: …". +pub fn texture_load_result_response( + source: &ImageSource<'_>, + tlr: &TextureLoadResult, + response: Response, +) -> Response { + match tlr { + Ok(TexturePoll::Ready { .. }) => response, + Ok(TexturePoll::Pending { .. }) => { + let uri = source.uri().unwrap_or("image"); + response.on_hover_text(format!("Loading {uri}…")) + } + Err(err) => { + let uri = source.uri().unwrap_or("image"); + response.on_hover_text(format!("Failed loading {uri}: {err}")) + } + } } impl<'a> From<&'a str> for ImageSource<'a> { #[inline] fn from(value: &'a str) -> Self { + Self::Uri(value.into()) + } +} + +impl<'a> From<&'a String> for ImageSource<'a> { + #[inline] + fn from(value: &'a String) -> Self { + Self::Uri(value.as_str().into()) + } +} + +impl From for ImageSource<'static> { + fn from(value: String) -> Self { + Self::Uri(value.into()) + } +} + +impl<'a> From<&'a Cow<'a, str>> for ImageSource<'a> { + #[inline] + fn from(value: &'a Cow<'a, str>) -> Self { + Self::Uri(value.clone()) + } +} + +impl<'a> From> for ImageSource<'a> { + #[inline] + fn from(value: Cow<'a, str>) -> Self { Self::Uri(value) } } @@ -271,124 +644,109 @@ impl<'a> From<&'a str> for ImageSource<'a> { impl> From<(&'static str, T)> for ImageSource<'static> { #[inline] fn from((uri, bytes): (&'static str, T)) -> Self { + Self::Bytes(uri.into(), bytes.into()) + } +} + +impl> From<(Cow<'static, str>, T)> for ImageSource<'static> { + #[inline] + fn from((uri, bytes): (Cow<'static, str>, T)) -> Self { Self::Bytes(uri, bytes.into()) } } -impl<'a> Image2<'a> { - /// Load the image from some source. - pub fn new(source: ImageSource<'a>) -> Self { - Self { - source, - texture_options: Default::default(), - size_hint: Default::default(), - fit: Default::default(), - sense: Sense::hover(), - } - } - - /// Load the image from a URI. - /// - /// See [`ImageSource::Uri`]. - pub fn from_uri(uri: &'a str) -> Self { - Self { - source: ImageSource::Uri(uri), - texture_options: Default::default(), - size_hint: Default::default(), - fit: Default::default(), - sense: Sense::hover(), - } - } - - /// Load the image from some raw `'static` bytes. - /// - /// For example, you can use this to load an image from bytes obtained via [`include_bytes`]. - /// - /// See [`ImageSource::Bytes`]. - pub fn from_static_bytes(name: &'static str, bytes: &'static [u8]) -> Self { - Self { - source: ImageSource::Bytes(name, Bytes::Static(bytes)), - texture_options: Default::default(), - size_hint: Default::default(), - fit: Default::default(), - sense: Sense::hover(), - } - } - - /// Load the image from some raw bytes. - /// - /// See [`ImageSource::Bytes`]. - pub fn from_bytes(name: &'static str, bytes: impl Into>) -> Self { - Self { - source: ImageSource::Bytes(name, Bytes::Shared(bytes.into())), - texture_options: Default::default(), - size_hint: Default::default(), - fit: Default::default(), - sense: Sense::hover(), - } - } - - /// Texture options used when creating the texture. +impl> From<(String, T)> for ImageSource<'static> { #[inline] - pub fn texture_options(mut self, texture_options: TextureOptions) -> Self { - self.texture_options = texture_options; - self - } - - /// Size hint used when creating the texture. - #[inline] - pub fn size_hint(mut self, size_hint: impl Into) -> Self { - self.size_hint = size_hint.into(); - self - } - - /// Make the image respond to clicks and/or drags. - #[inline] - pub fn sense(mut self, sense: Sense) -> Self { - self.sense = sense; - self + fn from((uri, bytes): (String, T)) -> Self { + Self::Bytes(uri.into(), bytes.into()) } } -impl<'a> Widget for Image2<'a> { - fn ui(self, ui: &mut Ui) -> Response { - let uri = match self.source { - ImageSource::Uri(uri) => uri, - ImageSource::Bytes(uri, bytes) => { - match bytes { - Bytes::Static(bytes) => ui.ctx().include_static_bytes(uri, bytes), - Bytes::Shared(bytes) => ui.ctx().include_bytes(uri, bytes), - } - uri - } - }; +impl> From for ImageSource<'static> { + fn from(value: T) -> Self { + Self::Texture(value.into()) + } +} - match ui - .ctx() - .try_load_texture(uri, self.texture_options, self.size_hint) - { - Ok(TexturePoll::Ready { texture }) => { - let final_size = self.fit.calculate_final_size( - ui.available_size(), - Vec2::new(texture.size[0] as f32, texture.size[1] as f32), - ); +#[derive(Debug, Clone)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct ImageOptions { + /// Select UV range. Default is (0,0) in top-left, (1,1) bottom right. + pub uv: Rect, - let (rect, response) = ui.allocate_exact_size(final_size, self.sense); + /// A solid color to put behind the image. Useful for transparent images. + pub bg_fill: Color32, - let mut mesh = Mesh::with_texture(texture.id); - mesh.add_rect_with_uv( - rect, - Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), - Color32::WHITE, - ); - ui.painter().add(Shape::mesh(mesh)); + /// Multiply image color with this. Default is WHITE (no tint). + pub tint: Color32, - response - } - Ok(TexturePoll::Pending { .. }) => { - ui.spinner().on_hover_text(format!("Loading {uri:?}…")) - } - Err(err) => ui.colored_label(ui.visuals().error_fg_color, err.to_string()), + /// Rotate the image about an origin by some angle + /// + /// Positive angle is clockwise. + /// Origin is a vector in normalized UV space ((0,0) in top-left, (1,1) bottom right). + /// + /// To rotate about the center you can pass `Vec2::splat(0.5)` as the origin. + /// + /// Due to limitations in the current implementation, + /// this will turn off rounding of the image. + pub rotation: Option<(Rot2, Vec2)>, + + /// Round the corners of the image. + /// + /// The default is no rounding ([`Rounding::ZERO`]). + /// + /// Due to limitations in the current implementation, + /// this will turn off any rotation of the image. + pub rounding: Rounding, +} + +impl Default for ImageOptions { + fn default() -> Self { + Self { + uv: Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), + bg_fill: Default::default(), + tint: Color32::WHITE, + rotation: None, + rounding: Rounding::ZERO, + } + } +} + +pub fn paint_texture_at( + painter: &Painter, + rect: Rect, + options: &ImageOptions, + texture: &SizedTexture, +) { + if options.bg_fill != Default::default() { + let mut mesh = Mesh::default(); + mesh.add_colored_rect(rect, options.bg_fill); + painter.add(Shape::mesh(mesh)); + } + + match options.rotation { + Some((rot, origin)) => { + // TODO(emilk): implement this using `PathShape` (add texture support to it). + // This will also give us anti-aliasing of rotated images. + egui_assert!( + options.rounding == Rounding::ZERO, + "Image had both rounding and rotation. Please pick only one" + ); + + let mut mesh = Mesh::with_texture(texture.id); + mesh.add_rect_with_uv(rect, options.uv, options.tint); + mesh.rotate(rot, rect.min + origin * rect.size()); + painter.add(Shape::mesh(mesh)); + } + None => { + painter.add(RectShape { + rect, + rounding: options.rounding, + fill: options.tint, + stroke: Stroke::NONE, + fill_texture_id: texture.id, + uv: options.uv, + }); } } } diff --git a/crates/egui/src/widgets/mod.rs b/crates/egui/src/widgets/mod.rs index c1193dba6..d589e386a 100644 --- a/crates/egui/src/widgets/mod.rs +++ b/crates/egui/src/widgets/mod.rs @@ -22,7 +22,7 @@ pub mod text_edit; pub use button::*; pub use drag_value::DragValue; pub use hyperlink::*; -pub use image::{Image, Image2, ImageSource}; +pub use image::{paint_texture_at, Image, ImageFit, ImageOptions, ImageSize, ImageSource}; pub use label::*; pub use progress_bar::ProgressBar; pub use selected_label::SelectableLabel; diff --git a/crates/egui/src/widgets/slider.rs b/crates/egui/src/widgets/slider.rs index 4314498c5..1d4c5f8d9 100644 --- a/crates/egui/src/widgets/slider.rs +++ b/crates/egui/src/widgets/slider.rs @@ -570,6 +570,16 @@ impl<'a> Slider<'a> { let mut increment = 0usize; if response.has_focus() { + ui.ctx().memory_mut(|m| { + m.set_focus_lock_filter( + response.id, + EventFilter { + arrows: true, // pressing arrows should not move focus to next widget + ..Default::default() + }, + ); + }); + let (dec_key, inc_key) = match self.orientation { SliderOrientation::Horizontal => (Key::ArrowLeft, Key::ArrowRight), // Note that this is for moving the slider position, @@ -595,13 +605,14 @@ impl<'a> Slider<'a> { let kb_step = increment as f32 - decrement as f32; if kb_step != 0.0 { + let ui_point_per_step = 1.0; // move this many ui points for each kb_step let prev_value = self.get_value(); let prev_position = self.position_from_value(prev_value, position_range); - let new_position = prev_position + kb_step; + let new_position = prev_position + ui_point_per_step * kb_step; let new_value = match self.step { Some(step) => prev_value + (kb_step as f64 * step), None if self.smart_aim => { - let aim_radius = ui.input(|i| i.aim_radius()); + let aim_radius = 0.49 * ui_point_per_step; // Chosen so we don't include `prev_value` in the search. emath::smart_aim::best_in_range_f64( self.value_from_position(new_position - aim_radius, position_range), self.value_from_position(new_position + aim_radius, position_range), diff --git a/crates/egui/src/widgets/spinner.rs b/crates/egui/src/widgets/spinner.rs index f2b253bc6..b6fb928de 100644 --- a/crates/egui/src/widgets/spinner.rs +++ b/crates/egui/src/widgets/spinner.rs @@ -1,4 +1,4 @@ -use epaint::{emath::lerp, vec2, Color32, Pos2, Shape, Stroke}; +use epaint::{emath::lerp, vec2, Color32, Pos2, Rect, Shape, Stroke}; use crate::{Response, Sense, Ui, Widget}; @@ -31,21 +31,15 @@ impl Spinner { self.color = Some(color.into()); self } -} - -impl Widget for Spinner { - fn ui(self, ui: &mut Ui) -> Response { - let size = self - .size - .unwrap_or_else(|| ui.style().spacing.interact_size.y); - let color = self - .color - .unwrap_or_else(|| ui.visuals().strong_text_color()); - let (rect, response) = ui.allocate_exact_size(vec2(size, size), Sense::hover()); + /// Paint the spinner in the given rectangle. + pub fn paint_at(&self, ui: &Ui, rect: Rect) { if ui.is_rect_visible(rect) { - ui.ctx().request_repaint(); + ui.ctx().request_repaint(); // because it is animated + let color = self + .color + .unwrap_or_else(|| ui.visuals().strong_text_color()); let radius = (rect.height() / 2.0) - 2.0; let n_points = 20; let time = ui.input(|i| i.time); @@ -61,6 +55,16 @@ impl Widget for Spinner { ui.painter() .add(Shape::line(points, Stroke::new(3.0, color))); } + } +} + +impl Widget for Spinner { + fn ui(self, ui: &mut Ui) -> Response { + let size = self + .size + .unwrap_or_else(|| ui.style().spacing.interact_size.y); + let (rect, response) = ui.allocate_exact_size(vec2(size, size), Sense::hover()); + self.paint_at(ui, rect); response } diff --git a/crates/egui/src/widgets/text_edit/builder.rs b/crates/egui/src/widgets/text_edit/builder.rs index 3019fa7fd..e19698977 100644 --- a/crates/egui/src/widgets/text_edit/builder.rs +++ b/crates/egui/src/widgets/text_edit/builder.rs @@ -65,7 +65,7 @@ pub struct TextEdit<'t> { interactive: bool, desired_width: Option, desired_height_rows: usize, - lock_focus: bool, + event_filter: EventFilter, cursor_at_end: bool, min_size: Vec2, align: Align2, @@ -115,7 +115,11 @@ impl<'t> TextEdit<'t> { interactive: true, desired_width: None, desired_height_rows: 4, - lock_focus: false, + event_filter: EventFilter { + arrows: true, // moving the cursor is really important + tab: false, // tab is used to change focus, not to insert a tab character + ..Default::default() + }, cursor_at_end: true, min_size: Vec2::ZERO, align: Align2::LEFT_TOP, @@ -127,7 +131,7 @@ impl<'t> TextEdit<'t> { /// Build a [`TextEdit`] focused on code editing. /// By default it comes with: /// - monospaced font - /// - focus lock + /// - focus lock (tab will insert a tab character instead of moving focus) pub fn code_editor(self) -> Self { self.font(TextStyle::Monospace).lock_focus(true) } @@ -266,8 +270,8 @@ impl<'t> TextEdit<'t> { /// /// When `true`, the widget will keep the focus and pressing TAB /// will insert the `'\t'` character. - pub fn lock_focus(mut self, b: bool) -> Self { - self.lock_focus = b; + pub fn lock_focus(mut self, tab_will_indent: bool) -> Self { + self.event_filter.tab = tab_will_indent; self } @@ -352,7 +356,9 @@ impl<'t> TextEdit<'t> { let margin = self.margin; let max_rect = ui.available_rect_before_wrap().shrink2(margin); let mut content_ui = ui.child_ui(max_rect, *ui.layout()); + let mut output = self.show_content(&mut content_ui); + let id = output.response.id; let frame_rect = output.response.rect.expand2(margin); ui.allocate_space(frame_rect.size()); @@ -413,7 +419,7 @@ impl<'t> TextEdit<'t> { interactive, desired_width, desired_height_rows, - lock_focus, + event_filter, cursor_at_end, min_size, align, @@ -569,7 +575,7 @@ impl<'t> TextEdit<'t> { let mut cursor_range = None; let prev_cursor_range = state.cursor_range(&galley); if interactive && ui.memory(|mem| mem.has_focus(id)) { - ui.memory_mut(|mem| mem.lock_focus(id, lock_focus)); + ui.memory_mut(|mem| mem.set_focus_lock_filter(id, event_filter)); let default_cursor_range = if cursor_at_end { CursorRange::one(galley.end()) @@ -589,6 +595,7 @@ impl<'t> TextEdit<'t> { password, default_cursor_range, char_limit, + event_filter, ); if changed { @@ -880,6 +887,7 @@ fn events( password: bool, default_cursor_range: CursorRange, char_limit: usize, + event_filter: EventFilter, ) -> (bool, CursorRange) { let mut cursor_range = state.cursor_range(galley).unwrap_or(default_cursor_range); @@ -898,7 +906,7 @@ fn events( let mut any_change = false; - let events = ui.input(|i| i.events.clone()); // avoid dead-lock by cloning. TODO(emilk): optimize + let events = ui.input(|i| i.filtered_events(&event_filter)); for event in &events { let did_mutate_text = match event { Event::Copy => { @@ -946,19 +954,15 @@ fn events( pressed: true, modifiers, .. - } => { - if multiline && ui.memory(|mem| mem.has_lock_focus(id)) { - let mut ccursor = delete_selected(text, &cursor_range); - if modifiers.shift { - // TODO(emilk): support removing indentation over a selection? - decrease_indentation(&mut ccursor, text); - } else { - insert_text(&mut ccursor, text, "\t", char_limit); - } - Some(CCursorRange::one(ccursor)) + } if multiline => { + let mut ccursor = delete_selected(text, &cursor_range); + if modifiers.shift { + // TODO(emilk): support removing indentation over a selection? + decrease_indentation(&mut ccursor, text); } else { - None + insert_text(&mut ccursor, text, "\t", char_limit); } + Some(CCursorRange::one(ccursor)) } Event::Key { key: Key::Enter, diff --git a/crates/egui/src/widgets/text_edit/text_buffer.rs b/crates/egui/src/widgets/text_edit/text_buffer.rs index e29a07a9f..a97d264a1 100644 --- a/crates/egui/src/widgets/text_edit/text_buffer.rs +++ b/crates/egui/src/widgets/text_edit/text_buffer.rs @@ -1,4 +1,4 @@ -use std::ops::Range; +use std::{borrow::Cow, ops::Range}; /// Trait constraining what types [`crate::TextEdit`] may use as /// an underlying buffer. @@ -100,6 +100,36 @@ impl TextBuffer for String { } } +impl<'a> TextBuffer for Cow<'a, str> { + fn is_mutable(&self) -> bool { + true + } + + fn as_str(&self) -> &str { + self.as_ref() + } + + fn insert_text(&mut self, text: &str, char_index: usize) -> usize { + ::insert_text(self.to_mut(), text, char_index) + } + + fn delete_char_range(&mut self, char_range: Range) { + ::delete_char_range(self.to_mut(), char_range); + } + + fn clear(&mut self) { + ::clear(self.to_mut()); + } + + fn replace(&mut self, text: &str) { + *self = Cow::Owned(text.to_owned()); + } + + fn take(&mut self) -> String { + std::mem::take(self).into_owned() + } +} + /// Immutable view of a `&str`! impl<'a> TextBuffer for &'a str { fn is_mutable(&self) -> bool { diff --git a/crates/egui_demo_app/Cargo.toml b/crates/egui_demo_app/Cargo.toml index ef35b1ccb..677e74639 100644 --- a/crates/egui_demo_app/Cargo.toml +++ b/crates/egui_demo_app/Cargo.toml @@ -19,15 +19,15 @@ crate-type = ["cdylib", "rlib"] default = ["glow", "persistence"] http = ["ehttp", "image", "poll-promise", "egui_extras/image"] +image_viewer = ["image", "egui_extras/all_loaders", "rfd"] persistence = ["eframe/persistence", "egui/persistence", "serde"] web_screen_reader = ["eframe/web_screen_reader"] # experimental serde = ["dep:serde", "egui_demo_lib/serde", "egui/serde"] -syntax_highlighting = ["egui_demo_lib/syntax_highlighting"] +syntect = ["egui_demo_lib/syntect"] glow = ["eframe/glow"] wgpu = ["eframe/wgpu", "bytemuck"] - [dependencies] chrono = { version = "0.4", default-features = false, features = [ "js-sys", @@ -36,6 +36,7 @@ chrono = { version = "0.4", default-features = false, features = [ eframe = { version = "0.22.0", path = "../eframe", default-features = false } egui = { version = "0.22.0", path = "../egui", features = [ "extra_debug_asserts", + "log", ] } egui_demo_lib = { version = "0.22.0", path = "../egui_demo_lib", features = [ "chrono", @@ -45,9 +46,10 @@ log = { version = "0.4", features = ["std"] } # Optional dependencies: bytemuck = { version = "1.7.1", optional = true } -egui_extras = { version = "0.22.0", optional = true, path = "../egui_extras", features = [ - "log", +egui_extras = { version = "0.22.0", path = "../egui_extras", features = [ + "image", ] } +rfd = { version = "0.11", optional = true } # feature "http": ehttp = { version = "0.3.0", optional = true } diff --git a/crates/egui_demo_app/src/apps/http_app.rs b/crates/egui_demo_app/src/apps/http_app.rs index e550c1dbc..ef5c2987a 100644 --- a/crates/egui_demo_app/src/apps/http_app.rs +++ b/crates/egui_demo_app/src/apps/http_app.rs @@ -1,4 +1,4 @@ -use egui_extras::RetainedImage; +use egui::Image; use poll_promise::Promise; struct Resource { @@ -8,7 +8,7 @@ struct Resource { text: Option, /// If set, the response was an image. - image: Option, + image: Option>, /// If set, the response was text with some supported syntax highlighting (e.g. ".rs" or ".md"). colored_text: Option, @@ -17,21 +17,27 @@ struct Resource { impl Resource { fn from_response(ctx: &egui::Context, response: ehttp::Response) -> Self { let content_type = response.content_type().unwrap_or_default(); - let image = if content_type.starts_with("image/") { - RetainedImage::from_image_bytes(&response.url, &response.bytes).ok() + if content_type.starts_with("image/") { + ctx.include_bytes(response.url.clone(), response.bytes.clone()); + let image = Image::from_uri(response.url.clone()); + + Self { + response, + text: None, + colored_text: None, + image: Some(image), + } } else { - None - }; + let text = response.text(); + let colored_text = text.and_then(|text| syntax_highlighting(ctx, &response, text)); + let text = text.map(|text| text.to_owned()); - let text = response.text(); - let colored_text = text.and_then(|text| syntax_highlighting(ctx, &response, text)); - let text = text.map(|text| text.to_owned()); - - Self { - response, - text, - image, - colored_text, + Self { + response, + text, + colored_text, + image: None, + } } } } @@ -63,6 +69,7 @@ impl eframe::App for HttpApp { }); egui::CentralPanel::default().show(ctx, |ui| { + let prev_url = self.url.clone(); let trigger_fetch = ui_url(ui, frame, &mut self.url); ui.horizontal_wrapped(|ui| { @@ -77,6 +84,7 @@ impl eframe::App for HttpApp { let (sender, promise) = Promise::new(); let request = ehttp::Request::get(&self.url); ehttp::fetch(request, move |response| { + ctx.forget_image(&prev_url); ctx.request_repaint(); // wake up UI thread let resource = response.map(|response| Resource::from_response(&ctx, response)); sender.send(resource); @@ -193,9 +201,7 @@ fn ui_resource(ui: &mut egui::Ui, resource: &Resource) { } if let Some(image) = image { - let mut size = image.size_vec2(); - size *= (ui.available_width() / size.x).min(1.0); - image.show_size(ui, size); + ui.add(image.clone()); } else if let Some(colored_text) = colored_text { colored_text.ui(ui); } else if let Some(text) = &text { @@ -217,25 +223,19 @@ fn selectable_text(ui: &mut egui::Ui, mut text: &str) { // ---------------------------------------------------------------------------- // Syntax highlighting: -#[cfg(feature = "syntect")] fn syntax_highlighting( ctx: &egui::Context, response: &ehttp::Response, text: &str, ) -> Option { let extension_and_rest: Vec<&str> = response.url.rsplitn(2, '.').collect(); - let extension = extension_and_rest.get(0)?; - let theme = crate::syntax_highlighting::CodeTheme::from_style(&ctx.style()); - Some(ColoredText(crate::syntax_highlighting::highlight( + let extension = extension_and_rest.first()?; + let theme = egui_extras::syntax_highlighting::CodeTheme::from_style(&ctx.style()); + Some(ColoredText(egui_extras::syntax_highlighting::highlight( ctx, &theme, text, extension, ))) } -#[cfg(not(feature = "syntect"))] -fn syntax_highlighting(_ctx: &egui::Context, _: &ehttp::Response, _: &str) -> Option { - None -} - struct ColoredText(egui::text::LayoutJob); impl ColoredText { diff --git a/crates/egui_demo_app/src/apps/image_viewer.rs b/crates/egui_demo_app/src/apps/image_viewer.rs new file mode 100644 index 000000000..6bc6cc05b --- /dev/null +++ b/crates/egui_demo_app/src/apps/image_viewer.rs @@ -0,0 +1,214 @@ +use egui::emath::Rot2; +use egui::panel::Side; +use egui::panel::TopBottomSide; +use egui::ImageFit; +use egui::Slider; +use egui::Vec2; + +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct ImageViewer { + current_uri: String, + uri_edit_text: String, + image_options: egui::ImageOptions, + chosen_fit: ChosenFit, + fit: ImageFit, + maintain_aspect_ratio: bool, + max_size: Vec2, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +enum ChosenFit { + ExactSize, + Fraction, + OriginalSize, +} + +impl ChosenFit { + fn as_str(&self) -> &'static str { + match self { + ChosenFit::ExactSize => "exact size", + ChosenFit::Fraction => "fraction", + ChosenFit::OriginalSize => "original size", + } + } +} + +impl Default for ImageViewer { + fn default() -> Self { + Self { + current_uri: "https://picsum.photos/seed/1.759706314/1024".to_owned(), + uri_edit_text: "https://picsum.photos/seed/1.759706314/1024".to_owned(), + image_options: egui::ImageOptions::default(), + chosen_fit: ChosenFit::Fraction, + fit: ImageFit::Fraction(Vec2::splat(1.0)), + maintain_aspect_ratio: true, + max_size: Vec2::splat(2048.0), + } + } +} + +impl eframe::App for ImageViewer { + fn update(&mut self, ctx: &egui::Context, _: &mut eframe::Frame) { + egui::TopBottomPanel::new(TopBottomSide::Top, "url bar").show(ctx, |ui| { + ui.horizontal_centered(|ui| { + ui.label("URI:"); + ui.text_edit_singleline(&mut self.uri_edit_text); + if ui.small_button("✔").clicked() { + ctx.forget_image(&self.current_uri); + self.uri_edit_text = self.uri_edit_text.trim().to_owned(); + self.current_uri = self.uri_edit_text.clone(); + }; + + #[cfg(not(target_arch = "wasm32"))] + if ui.button("file…").clicked() { + if let Some(path) = rfd::FileDialog::new().pick_file() { + self.uri_edit_text = format!("file://{}", path.display()); + self.current_uri = self.uri_edit_text.clone(); + } + } + }); + }); + + egui::SidePanel::new(Side::Left, "controls").show(ctx, |ui| { + // uv + ui.label("UV"); + ui.add(Slider::new(&mut self.image_options.uv.min.x, 0.0..=1.0).text("min x")); + ui.add(Slider::new(&mut self.image_options.uv.min.y, 0.0..=1.0).text("min y")); + ui.add(Slider::new(&mut self.image_options.uv.max.x, 0.0..=1.0).text("max x")); + ui.add(Slider::new(&mut self.image_options.uv.max.y, 0.0..=1.0).text("max y")); + + // rotation + ui.add_space(2.0); + let had_rotation = self.image_options.rotation.is_some(); + let mut has_rotation = had_rotation; + ui.checkbox(&mut has_rotation, "Rotation"); + match (had_rotation, has_rotation) { + (true, false) => self.image_options.rotation = None, + (false, true) => { + self.image_options.rotation = + Some((Rot2::from_angle(0.0), Vec2::new(0.5, 0.5))); + } + (true, true) | (false, false) => {} + } + + if let Some((rot, origin)) = self.image_options.rotation.as_mut() { + let mut angle = rot.angle(); + + ui.label("angle"); + ui.drag_angle(&mut angle); + *rot = Rot2::from_angle(angle); + + ui.add(Slider::new(&mut origin.x, 0.0..=1.0).text("origin x")); + ui.add(Slider::new(&mut origin.y, 0.0..=1.0).text("origin y")); + } + + // bg_fill + ui.add_space(2.0); + ui.horizontal(|ui| { + ui.color_edit_button_srgba(&mut self.image_options.bg_fill); + ui.label("Background color"); + }); + + // tint + ui.add_space(2.0); + ui.horizontal(|ui| { + ui.color_edit_button_srgba(&mut self.image_options.tint); + ui.label("Tint"); + }); + + // fit + ui.add_space(10.0); + ui.label( + "The chosen fit will determine how the image tries to fill the available space", + ); + egui::ComboBox::from_label("Fit") + .selected_text(self.chosen_fit.as_str()) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.chosen_fit, + ChosenFit::ExactSize, + ChosenFit::ExactSize.as_str(), + ); + ui.selectable_value( + &mut self.chosen_fit, + ChosenFit::Fraction, + ChosenFit::Fraction.as_str(), + ); + ui.selectable_value( + &mut self.chosen_fit, + ChosenFit::OriginalSize, + ChosenFit::OriginalSize.as_str(), + ); + }); + + match self.chosen_fit { + ChosenFit::ExactSize => { + if !matches!(self.fit, ImageFit::Exact(_)) { + self.fit = ImageFit::Exact(Vec2::splat(128.0)); + } + let ImageFit::Exact(size) = &mut self.fit else { + unreachable!() + }; + ui.add(Slider::new(&mut size.x, 0.0..=2048.0).text("width")); + ui.add(Slider::new(&mut size.y, 0.0..=2048.0).text("height")); + } + ChosenFit::Fraction => { + if !matches!(self.fit, ImageFit::Fraction(_)) { + self.fit = ImageFit::Fraction(Vec2::splat(1.0)); + } + let ImageFit::Fraction(fract) = &mut self.fit else { + unreachable!() + }; + ui.add(Slider::new(&mut fract.x, 0.0..=1.0).text("width")); + ui.add(Slider::new(&mut fract.y, 0.0..=1.0).text("height")); + } + ChosenFit::OriginalSize => { + if !matches!(self.fit, ImageFit::Original { .. }) { + self.fit = ImageFit::Original { scale: 1.0 }; + } + let ImageFit::Original { scale } = &mut self.fit else { + unreachable!() + }; + ui.add(Slider::new(scale, 0.1..=4.0).text("scale")); + } + } + + // max size + ui.add_space(5.0); + ui.label("The calculated size will not exceed the maximum size"); + ui.add(Slider::new(&mut self.max_size.x, 0.0..=2048.0).text("width")); + ui.add(Slider::new(&mut self.max_size.y, 0.0..=2048.0).text("height")); + + // aspect ratio + ui.add_space(5.0); + ui.label("Aspect ratio is maintained by scaling both sides as necessary"); + ui.checkbox(&mut self.maintain_aspect_ratio, "Maintain aspect ratio"); + }); + + egui::CentralPanel::default().show(ctx, |ui| { + egui::ScrollArea::new([true, true]).show(ui, |ui| { + let mut image = egui::Image::from_uri(&self.current_uri); + image = image.uv(self.image_options.uv); + image = image.bg_fill(self.image_options.bg_fill); + image = image.tint(self.image_options.tint); + let (angle, origin) = self + .image_options + .rotation + .map_or((0.0, Vec2::splat(0.5)), |(rot, origin)| { + (rot.angle(), origin) + }); + image = image.rotate(angle, origin); + match self.fit { + ImageFit::Original { scale } => image = image.fit_to_original_size(scale), + ImageFit::Fraction(fract) => image = image.fit_to_fraction(fract), + ImageFit::Exact(size) => image = image.fit_to_exact_size(size), + } + image = image.maintain_aspect_ratio(self.maintain_aspect_ratio); + image = image.max_size(self.max_size); + + ui.add_sized(ui.available_size(), image); + }); + }); + } +} diff --git a/crates/egui_demo_app/src/apps/mod.rs b/crates/egui_demo_app/src/apps/mod.rs index 1e28bbd6b..33368bac6 100644 --- a/crates/egui_demo_app/src/apps/mod.rs +++ b/crates/egui_demo_app/src/apps/mod.rs @@ -9,6 +9,12 @@ mod fractal_clock; #[cfg(feature = "http")] mod http_app; +#[cfg(feature = "image_viewer")] +mod image_viewer; + +#[cfg(feature = "image_viewer")] +pub use image_viewer::ImageViewer; + #[cfg(all(feature = "glow", not(feature = "wgpu")))] pub use custom3d_glow::Custom3d; diff --git a/crates/egui_demo_app/src/wrap_app.rs b/crates/egui_demo_app/src/wrap_app.rs index 84581d563..d2a0e9102 100644 --- a/crates/egui_demo_app/src/wrap_app.rs +++ b/crates/egui_demo_app/src/wrap_app.rs @@ -82,6 +82,8 @@ enum Anchor { EasyMarkEditor, #[cfg(feature = "http")] Http, + #[cfg(feature = "image_viewer")] + ImageViewer, Clock, #[cfg(any(feature = "glow", feature = "wgpu"))] Custom3d, @@ -142,6 +144,8 @@ pub struct State { easy_mark_editor: EasyMarkApp, #[cfg(feature = "http")] http: crate::apps::HttpApp, + #[cfg(feature = "image_viewer")] + image_viewer: crate::apps::ImageViewer, clock: FractalClockApp, color_test: ColorTestApp, @@ -161,6 +165,8 @@ pub struct WrapApp { impl WrapApp { pub fn new(_cc: &eframe::CreationContext<'_>) -> Self { + egui_extras::install_image_loaders(&_cc.egui_ctx); + #[allow(unused_mut)] let mut slf = Self { state: State::default(), @@ -204,6 +210,12 @@ impl WrapApp { Anchor::Clock, &mut self.state.clock as &mut dyn eframe::App, ), + #[cfg(feature = "image_viewer")] + ( + "🖼 Image Viewer", + Anchor::ImageViewer, + &mut self.state.image_viewer as &mut dyn eframe::App, + ), ]; #[cfg(any(feature = "glow", feature = "wgpu"))] diff --git a/crates/egui_demo_lib/Cargo.toml b/crates/egui_demo_lib/Cargo.toml index 3fd6a6939..33b927fa4 100644 --- a/crates/egui_demo_lib/Cargo.toml +++ b/crates/egui_demo_lib/Cargo.toml @@ -28,16 +28,13 @@ chrono = ["egui_extras/datepicker", "dep:chrono"] serde = ["egui/serde", "egui_plot/serde", "dep:serde"] ## Enable better syntax highlighting using [`syntect`](https://docs.rs/syntect). -syntax_highlighting = ["syntect"] +syntect = ["egui_extras/syntect"] [dependencies] egui = { version = "0.22.0", path = "../egui", default-features = false } -egui_extras = { version = "0.22.0", path = "../egui_extras", features = [ - "log", -] } +egui_extras = { version = "0.22.0", path = "../egui_extras" } egui_plot = { version = "0.22.0", path = "../egui_plot" } -enum-map = { version = "2", features = ["serde"] } log = { version = "0.4", features = ["std"] } unicode_names2 = { version = "0.6.0", default-features = false } @@ -46,9 +43,6 @@ chrono = { version = "0.4", optional = true, features = ["js-sys", "wasmbind"] } ## Enable this when generating docs. document-features = { version = "0.2", optional = true } serde = { version = "1", optional = true, features = ["derive"] } -syntect = { version = "5", optional = true, default-features = false, features = [ - "default-fancy", -] } [dev-dependencies] diff --git a/crates/egui_demo_lib/assets/icon.png b/crates/egui_demo_lib/assets/icon.png new file mode 100644 index 000000000..87f15e746 Binary files /dev/null and b/crates/egui_demo_lib/assets/icon.png differ diff --git a/crates/egui_demo_lib/src/color_test.rs b/crates/egui_demo_lib/src/color_test.rs index 9f209f9d5..ebbb3076f 100644 --- a/crates/egui_demo_lib/src/color_test.rs +++ b/crates/egui_demo_lib/src/color_test.rs @@ -87,8 +87,12 @@ impl ColorTest { let tex = self.tex_mngr.get(ui.ctx(), &g); let texel_offset = 0.5 / (g.0.len() as f32); let uv = Rect::from_min_max(pos2(texel_offset, 0.0), pos2(1.0 - texel_offset, 1.0)); - ui.add(Image::new(tex, GRADIENT_SIZE).tint(vertex_color).uv(uv)) - .on_hover_text(format!("A texture that is {} texels wide", g.0.len())); + ui.add( + Image::from_texture((tex.id(), GRADIENT_SIZE)) + .tint(vertex_color) + .uv(uv), + ) + .on_hover_text(format!("A texture that is {} texels wide", g.0.len())); ui.label("GPU result"); }); }); @@ -225,11 +229,15 @@ impl ColorTest { let tex = self.tex_mngr.get(ui.ctx(), gradient); let texel_offset = 0.5 / (gradient.0.len() as f32); let uv = Rect::from_min_max(pos2(texel_offset, 0.0), pos2(1.0 - texel_offset, 1.0)); - ui.add(Image::new(tex, GRADIENT_SIZE).bg_fill(bg_fill).uv(uv)) - .on_hover_text(format!( - "A texture that is {} texels wide", - gradient.0.len() - )); + ui.add( + Image::from_texture((tex.id(), GRADIENT_SIZE)) + .bg_fill(bg_fill) + .uv(uv), + ) + .on_hover_text(format!( + "A texture that is {} texels wide", + gradient.0.len() + )); ui.label(label); }); } diff --git a/crates/egui_demo_lib/src/demo/about.rs b/crates/egui_demo_lib/src/demo/about.rs index 4972b224b..57cbcd0c5 100644 --- a/crates/egui_demo_lib/src/demo/about.rs +++ b/crates/egui_demo_lib/src/demo/about.rs @@ -45,7 +45,6 @@ impl super::View for About { } fn about_immediate_mode(ui: &mut egui::Ui) { - use crate::syntax_highlighting::code_view_ui; ui.style_mut().spacing.interact_size.y = 0.0; // hack to make `horizontal_wrapped` work better with text. ui.horizontal_wrapped(|ui| { @@ -56,7 +55,7 @@ fn about_immediate_mode(ui: &mut egui::Ui) { }); ui.add_space(8.0); - code_view_ui( + crate::rust_view_ui( ui, r#" if ui.button("Save").clicked() { diff --git a/crates/egui_demo_lib/src/demo/code_editor.rs b/crates/egui_demo_lib/src/demo/code_editor.rs index e9632a208..2a8f6a6c9 100644 --- a/crates/egui_demo_lib/src/demo/code_editor.rs +++ b/crates/egui_demo_lib/src/demo/code_editor.rs @@ -67,7 +67,7 @@ impl super::View for CodeEditor { }); } - let mut theme = crate::syntax_highlighting::CodeTheme::from_memory(ui.ctx()); + let mut theme = egui_extras::syntax_highlighting::CodeTheme::from_memory(ui.ctx()); ui.collapsing("Theme", |ui| { ui.group(|ui| { theme.ui(ui); @@ -77,7 +77,7 @@ impl super::View for CodeEditor { let mut layouter = |ui: &egui::Ui, string: &str, wrap_width: f32| { let mut layout_job = - crate::syntax_highlighting::highlight(ui.ctx(), &theme, string, language); + egui_extras::syntax_highlighting::highlight(ui.ctx(), &theme, string, language); layout_job.wrap.max_width = wrap_width; ui.fonts(|f| f.layout_job(layout_job)) }; diff --git a/crates/egui_demo_lib/src/demo/code_example.rs b/crates/egui_demo_lib/src/demo/code_example.rs index 7b1351476..7c97daccc 100644 --- a/crates/egui_demo_lib/src/demo/code_example.rs +++ b/crates/egui_demo_lib/src/demo/code_example.rs @@ -81,13 +81,11 @@ impl super::Demo for CodeExample { impl super::View for CodeExample { fn ui(&mut self, ui: &mut egui::Ui) { - use crate::syntax_highlighting::code_view_ui; - ui.vertical_centered(|ui| { ui.add(crate::egui_github_link_file!()); }); - code_view_ui( + crate::rust_view_ui( ui, r" pub struct CodeExample { @@ -117,15 +115,15 @@ impl CodeExample { }); }); - code_view_ui(ui, " }\n}"); + crate::rust_view_ui(ui, " }\n}"); ui.separator(); - code_view_ui(ui, &format!("{self:#?}")); + crate::rust_view_ui(ui, &format!("{self:#?}")); ui.separator(); - let mut theme = crate::syntax_highlighting::CodeTheme::from_memory(ui.ctx()); + let mut theme = egui_extras::syntax_highlighting::CodeTheme::from_memory(ui.ctx()); ui.collapsing("Theme", |ui| { theme.ui(ui); theme.store_in_memory(ui.ctx()); @@ -135,7 +133,7 @@ impl CodeExample { fn show_code(ui: &mut egui::Ui, code: &str) { let code = remove_leading_indentation(code.trim_start_matches('\n')); - crate::syntax_highlighting::code_view_ui(ui, &code); + crate::rust_view_ui(ui, &code); } fn remove_leading_indentation(code: &str) -> String { diff --git a/crates/egui_demo_lib/src/demo/widget_gallery.rs b/crates/egui_demo_lib/src/demo/widget_gallery.rs index 54c083504..9cc7d2ca6 100644 --- a/crates/egui_demo_lib/src/demo/widget_gallery.rs +++ b/crates/egui_demo_lib/src/demo/widget_gallery.rs @@ -21,9 +21,6 @@ pub struct WidgetGallery { #[cfg(feature = "chrono")] #[cfg_attr(feature = "serde", serde(skip))] date: Option, - - #[cfg_attr(feature = "serde", serde(skip))] - texture: Option, } impl Default for WidgetGallery { @@ -39,7 +36,6 @@ impl Default for WidgetGallery { animate_progress_bar: false, #[cfg(feature = "chrono")] date: None, - texture: None, } } } @@ -111,14 +107,8 @@ impl WidgetGallery { animate_progress_bar, #[cfg(feature = "chrono")] date, - texture, } = self; - let texture: &egui::TextureHandle = texture.get_or_insert_with(|| { - ui.ctx() - .load_texture("example", egui::ColorImage::example(), Default::default()) - }); - ui.add(doc_link_label("Label", "label,heading")); ui.label("Welcome to the widget gallery!"); ui.end_row(); @@ -206,14 +196,19 @@ impl WidgetGallery { ui.color_edit_button_srgba(color); ui.end_row(); - let img_size = 16.0 * texture.size_vec2() / texture.size_vec2().y; - ui.add(doc_link_label("Image", "Image")); - ui.image(texture, img_size); + let egui_icon = egui::include_image!("../../assets/icon.png"); + ui.add(egui::Image::new(egui_icon.clone())); ui.end_row(); - ui.add(doc_link_label("ImageButton", "ImageButton")); - if ui.add(egui::ImageButton::new(texture, img_size)).clicked() { + ui.add(doc_link_label( + "Button with image", + "Button::image_and_text", + )); + if ui + .add(egui::Button::image_and_text(egui_icon, "Click me!")) + .clicked() + { *boolean = !*boolean; } ui.end_row(); diff --git a/crates/egui_demo_lib/src/lib.rs b/crates/egui_demo_lib/src/lib.rs index 13ff82bca..0031eada5 100644 --- a/crates/egui_demo_lib/src/lib.rs +++ b/crates/egui_demo_lib/src/lib.rs @@ -15,13 +15,19 @@ mod color_test; mod demo; pub mod easy_mark; -pub mod syntax_highlighting; pub use color_test::ColorTest; pub use demo::DemoWindows; #[cfg(test)] use egui::ViewportId; +/// View some Rust code with syntax highlighting and selection. +pub(crate) fn rust_view_ui(ui: &mut egui::Ui, code: &str) { + let language = "rs"; + let theme = egui_extras::syntax_highlighting::CodeTheme::from_memory(ui.ctx()); + egui_extras::syntax_highlighting::code_view_ui(ui, &theme, code, language); +} + // ---------------------------------------------------------------------------- /// Create a [`Hyperlink`](egui::Hyperlink) to this egui source code file on github. diff --git a/crates/egui_extras/Cargo.toml b/crates/egui_extras/Cargo.toml index 25ec2acda..d2d21068d 100644 --- a/crates/egui_extras/Cargo.toml +++ b/crates/egui_extras/Cargo.toml @@ -24,20 +24,28 @@ all-features = true [features] -default = [] +default = ["dep:mime_guess"] -## Shorthand for enabling `svg`, `image`, and `ehttp`. -all-loaders = ["svg", "image", "http"] +## Shorthand for enabling the different types of image loaders (`file`, `http`, `image`, `svg`). +all_loaders = ["file", "http", "image", "svg"] ## Enable [`DatePickerButton`] widget. datepicker = ["chrono"] -## Log warnings using [`log`](https://docs.rs/log) crate. -log = ["dep:log", "egui/log"] +## Add support for loading images from `file://` URIs. +file = ["dep:mime_guess"] ## Add support for loading images via HTTP. http = ["dep:ehttp"] +## Add support for loading images with the [`image`](https://docs.rs/image) crate. +## +## You also need to ALSO opt-in to the image formats you want to support, like so: +## ```toml +## image = { version = "0.24", features = ["jpeg", "png"] } +## ``` +image = ["dep:image"] + ## Enable profiling with the [`puffin`](https://docs.rs/puffin) crate. ## ## Only enabled on native, because of the low resolution (1ms) of clocks in browsers. @@ -46,9 +54,16 @@ puffin = ["dep:puffin", "egui/puffin"] ## Support loading svg images. svg = ["resvg", "tiny-skia", "usvg"] -[dependencies] -egui = { version = "0.22.0", path = "../egui", default-features = false } +## Enable better syntax highlighting using [`syntect`](https://docs.rs/syntect). +syntect = ["dep:syntect"] + +[dependencies] +egui = { version = "0.22.0", path = "../egui", default-features = false, features = [ + "serde", +] } +enum-map = { version = "2", features = ["serde"] } +log = { version = "0.4", features = ["std"] } serde = { version = "1", features = ["derive"] } #! ### Optional dependencies @@ -64,19 +79,17 @@ chrono = { version = "0.4", optional = true, default-features = false, features ## Enable this when generating docs. document-features = { version = "0.2", optional = true } -## Add support for loading images with the [`image`](https://docs.rs/image) crate. -## -## You also need to ALSO opt-in to the image formats you want to support, like so: -## ```toml -## image = { version = "0.24", features = ["jpeg", "png"] } -## ``` image = { version = "0.24", optional = true, default-features = false } -# feature "log" -log = { version = "0.4", optional = true, features = ["std"] } +# file feature +mime_guess = { version = "2.0.4", optional = true, default-features = false } puffin = { version = "0.16", optional = true } +syntect = { version = "5", optional = true, default-features = false, features = [ + "default-fancy", +] } + # svg feature resvg = { version = "0.28", optional = true, default-features = false } tiny-skia = { version = "0.8", optional = true, default-features = false } # must be updated in lock-step with resvg diff --git a/crates/egui_extras/src/image.rs b/crates/egui_extras/src/image.rs index 9304f2b80..de5573259 100644 --- a/crates/egui_extras/src/image.rs +++ b/crates/egui_extras/src/image.rs @@ -1,3 +1,5 @@ +#![allow(deprecated)] + use egui::{mutex::Mutex, TextureFilter, TextureOptions}; #[cfg(feature = "svg")] @@ -8,6 +10,9 @@ pub use usvg::FitTo; /// Load once, and save somewhere in your app state. /// /// Use the `svg` and `image` features to enable more constructors. +/// +/// ⚠ This type is deprecated: Consider using [`egui::Image`] instead. +#[deprecated = "consider using `egui::Image` instead"] pub struct RetainedImage { debug_name: String, @@ -151,7 +156,7 @@ impl RetainedImage { &self.debug_name } - /// The texture if for this image. + /// The texture id for this image. pub fn texture_id(&self, ctx: &egui::Context) -> egui::TextureId { self.texture .lock() @@ -186,7 +191,7 @@ impl RetainedImage { // We need to convert the SVG to a texture to display it: // Future improvement: tell backend to do mip-mapping of the image to // make it look smoother when downsized. - ui.image(self.texture_id(ui.ctx()), desired_size) + ui.image((self.texture_id(ui.ctx()), desired_size)) } } diff --git a/crates/egui_extras/src/lib.rs b/crates/egui_extras/src/lib.rs index 6ae107452..64bd808be 100644 --- a/crates/egui_extras/src/lib.rs +++ b/crates/egui_extras/src/lib.rs @@ -13,6 +13,9 @@ #[cfg(feature = "chrono")] mod datepicker; +pub mod syntax_highlighting; + +#[doc(hidden)] pub mod image; mod layout; pub mod loaders; @@ -23,12 +26,16 @@ mod table; #[cfg(feature = "chrono")] pub use crate::datepicker::DatePickerButton; +#[doc(hidden)] +#[allow(deprecated)] pub use crate::image::RetainedImage; pub(crate) use crate::layout::StripLayout; pub use crate::sizing::Size; pub use crate::strip::*; pub use crate::table::*; +pub use loaders::install_image_loaders; + // --------------------------------------------------------------------------- mod profiling_scopes { @@ -38,8 +45,8 @@ mod profiling_scopes { /// Profiling macro for feature "puffin" macro_rules! profile_function { ($($arg: tt)*) => { - #[cfg(not(target_arch = "wasm32"))] // Disabled on web because of the coarse 1ms clock resolution there. #[cfg(feature = "puffin")] + #[cfg(not(target_arch = "wasm32"))] // Disabled on web because of the coarse 1ms clock resolution there. puffin::profile_function!($($arg)*); }; } @@ -48,8 +55,8 @@ mod profiling_scopes { /// Profiling macro for feature "puffin" macro_rules! profile_scope { ($($arg: tt)*) => { - #[cfg(not(target_arch = "wasm32"))] // Disabled on web because of the coarse 1ms clock resolution there. #[cfg(feature = "puffin")] + #[cfg(not(target_arch = "wasm32"))] // Disabled on web because of the coarse 1ms clock resolution there. puffin::profile_scope!($($arg)*); }; } @@ -61,56 +68,15 @@ pub(crate) use profiling_scopes::*; // --------------------------------------------------------------------------- -/// Log an error with either `log` or `eprintln` -macro_rules! log_err { - ($fmt: literal, $($arg: tt)*) => {{ - #[cfg(feature = "log")] - log::error!($fmt, $($arg)*); - - #[cfg(not(feature = "log"))] - eprintln!( - concat!("egui_extras: ", $fmt), $($arg)* - ); - }}; -} -pub(crate) use log_err; - /// Panic in debug builds, log otherwise. macro_rules! log_or_panic { + ($fmt: literal) => {$crate::log_or_panic!($fmt,)}; ($fmt: literal, $($arg: tt)*) => {{ if cfg!(debug_assertions) { panic!($fmt, $($arg)*); } else { - $crate::log_err!($fmt, $($arg)*); + log::error!($fmt, $($arg)*); } }}; } pub(crate) use log_or_panic; - -#[allow(unused_macros)] -macro_rules! log_warn { - ($fmt: literal) => {$crate::log_warn!($fmt,)}; - ($fmt: literal, $($arg: tt)*) => {{ - #[cfg(feature = "log")] - log::warn!($fmt, $($arg)*); - - #[cfg(not(feature = "log"))] - println!( - concat!("egui_extras: warning: ", $fmt), $($arg)* - ) - }}; -} - -#[allow(unused_imports)] -pub(crate) use log_warn; - -#[allow(unused_macros)] -macro_rules! log_trace { - ($fmt: literal) => {$crate::log_trace!($fmt,)}; - ($fmt: literal, $($arg: tt)*) => {{ - #[cfg(feature = "log")] - log::trace!($fmt, $($arg)*); - }}; -} -#[allow(unused_imports)] -pub(crate) use log_trace; diff --git a/crates/egui_extras/src/loaders.rs b/crates/egui_extras/src/loaders.rs index 717d4ddc7..0342fcabc 100644 --- a/crates/egui_extras/src/loaders.rs +++ b/crates/egui_extras/src/loaders.rs @@ -1,46 +1,96 @@ // TODO: automatic cache eviction -/// Installs the default set of loaders: -/// - `file` loader on non-Wasm targets -/// - `http` loader (with the `ehttp` feature) -/// - `image` loader (with the `image` feature) -/// - `svg` loader with the `svg` feature +/// Installs a set of image loaders. /// -/// ⚠ This will do nothing and you won't see any images unless you enable some features! -/// If you just want to be able to load `file://` and `http://` images, enable the `all-loaders` feature. +/// Calling this enables the use of [`egui::Image`] and [`egui::Ui::image`]. /// -/// ⚠ The supported set of image formats is configured by adding the [`image`](https://crates.io/crates/image) +/// ⚠ This will do nothing and you won't see any images unless you also enable some feature flags on `egui_extras`: +/// +/// - `file` feature: `file://` loader on non-Wasm targets +/// - `http` feature: `http(s)://` loader +/// - `image` feature: Loader of png, jpeg etc using the [`image`] crate +/// - `svg` feature: `.svg` loader +/// +/// Calling this multiple times on the same [`egui::Context`] is safe. +/// It will never install duplicate loaders. +/// +/// - If you just want to be able to load `file://` and `http://` URIs, enable the `all_loaders` feature. +/// - The supported set of image formats is configured by adding the [`image`](https://crates.io/crates/image) /// crate as your direct dependency, and enabling features on it: /// /// ```toml,ignore +/// egui_extras = { version = "*", features = ["all_loaders"] } /// image = { version = "0.24", features = ["jpeg", "png"] } /// ``` /// +/// ⚠ You have to configure both the supported loaders in `egui_extras` _and_ the supported image formats +/// in `image` to get any output! +/// +/// ## Loader-specific information +/// +/// ⚠ The exact way bytes, images, and textures are loaded is subject to change, +/// but the supported protocols and file extensions are not. +/// +/// The `file` loader is a [`BytesLoader`][`egui::load::BytesLoader`]. +/// It will attempt to load `file://` URIs, and infer the content type from the extension. +/// The path will be passed to [`std::fs::read`] after trimming the `file://` prefix, +/// and is resolved the same way as with `std::fs::read(path)`: +/// - Relative paths are relative to the current working directory +/// - Absolute paths are left as is. +/// +/// The `http` loader is a [`BytesLoader`][`egui::load::BytesLoader`]. +/// It will attempt to load `http://` and `https://` URIs, and infer the content type from the `Content-Type` header. +/// +/// The `image` loader is an [`ImageLoader`][`egui::load::ImageLoader`]. +/// It will attempt to load any URI with any extension other than `svg`. +/// It will also try to load any URI without an extension. +/// The content type specified by [`BytesPoll::Ready::mime`][`egui::load::BytesPoll::Ready::mime`] always takes precedence. +/// This means that even if the URI has a `png` extension, and the `png` image format is enabled, if the content type is +/// not one of the supported and enabled image formats, the loader will return [`LoadError::NotSupported`][`egui::load::LoadError::NotSupported`], +/// allowing a different loader to attempt to load the image. +/// +/// The `svg` loader is an [`ImageLoader`][`egui::load::ImageLoader`]. +/// It will attempt to load any URI with an `svg` extension. It will _not_ attempt to load a URI without an extension. +/// The content type specified by [`BytesPoll::Ready::mime`][`egui::load::BytesPoll::Ready::mime`] always takes precedence, +/// and must include `svg` for it to be considered supported. For example, `image/svg+xml` would be loaded by the `svg` loader. +/// /// See [`egui::load`] for more information about how loaders work. -pub fn install(ctx: &egui::Context) { - #[cfg(not(target_arch = "wasm32"))] - ctx.add_bytes_loader(std::sync::Arc::new(self::file_loader::FileLoader::default())); +pub fn install_image_loaders(ctx: &egui::Context) { + #[cfg(all(not(target_arch = "wasm32"), feature = "file"))] + if !ctx.is_loader_installed(self::file_loader::FileLoader::ID) { + ctx.add_bytes_loader(std::sync::Arc::new(self::file_loader::FileLoader::default())); + log::trace!("installed FileLoader"); + } #[cfg(feature = "http")] - ctx.add_bytes_loader(std::sync::Arc::new( - self::ehttp_loader::EhttpLoader::default(), - )); + if !ctx.is_loader_installed(self::ehttp_loader::EhttpLoader::ID) { + ctx.add_bytes_loader(std::sync::Arc::new( + self::ehttp_loader::EhttpLoader::default(), + )); + log::trace!("installed EhttpLoader"); + } #[cfg(feature = "image")] - ctx.add_image_loader(std::sync::Arc::new( - self::image_loader::ImageCrateLoader::default(), - )); + if !ctx.is_loader_installed(self::image_loader::ImageCrateLoader::ID) { + ctx.add_image_loader(std::sync::Arc::new( + self::image_loader::ImageCrateLoader::default(), + )); + log::trace!("installed ImageCrateLoader"); + } #[cfg(feature = "svg")] - ctx.add_image_loader(std::sync::Arc::new(self::svg_loader::SvgLoader::default())); + if !ctx.is_loader_installed(self::svg_loader::SvgLoader::ID) { + ctx.add_image_loader(std::sync::Arc::new(self::svg_loader::SvgLoader::default())); + log::trace!("installed SvgLoader"); + } #[cfg(all( - target_arch = "wasm32", + any(target_arch = "wasm32", not(feature = "file")), not(feature = "http"), not(feature = "image"), not(feature = "svg") ))] - crate::log_warn!("`loaders::install` was called, but no loaders are enabled"); + log::warn!("`install_image_loaders` was called, but no loaders are enabled"); let _ = ctx; } diff --git a/crates/egui_extras/src/loaders/ehttp_loader.rs b/crates/egui_extras/src/loaders/ehttp_loader.rs index 9467eafd5..8cc9a6e71 100644 --- a/crates/egui_extras/src/loaders/ehttp_loader.rs +++ b/crates/egui_extras/src/loaders/ehttp_loader.rs @@ -5,52 +5,60 @@ use egui::{ }; use std::{sync::Arc, task::Poll}; -type Entry = Poll, String>>; +#[derive(Clone)] +struct File { + bytes: Arc<[u8]>, + mime: Option, +} + +impl File { + fn from_response(uri: &str, response: ehttp::Response) -> Result { + if !response.ok { + match response.text() { + Some(response_text) => { + return Err(format!( + "failed to load {uri:?}: {} {} {response_text}", + response.status, response.status_text + )) + } + None => { + return Err(format!( + "failed to load {uri:?}: {} {}", + response.status, response.status_text + )) + } + } + } + + let mime = response.content_type().map(|v| v.to_owned()); + let bytes = response.bytes.into(); + + Ok(File { bytes, mime }) + } +} + +type Entry = Poll>; #[derive(Default)] pub struct EhttpLoader { cache: Arc>>, } +impl EhttpLoader { + pub const ID: &str = egui::generate_loader_id!(EhttpLoader); +} + const PROTOCOLS: &[&str] = &["http://", "https://"]; fn starts_with_one_of(s: &str, prefixes: &[&str]) -> bool { prefixes.iter().any(|prefix| s.starts_with(prefix)) } -fn get_image_bytes( - uri: &str, - response: Result, -) -> Result, String> { - let response = response?; - if !response.ok { - match response.text() { - Some(response_text) => { - return Err(format!( - "failed to load {uri:?}: {} {} {response_text}", - response.status, response.status_text - )) - } - None => { - return Err(format!( - "failed to load {uri:?}: {} {}", - response.status, response.status_text - )) - } - } - } - - let Some(content_type) = response.content_type() else { - return Err(format!("failed to load {uri:?}: no content-type header found")); - }; - if !content_type.starts_with("image/") { - return Err(format!("failed to load {uri:?}: expected content-type starting with \"image/\", found {content_type:?}")); - } - - Ok(response.bytes.into()) -} - impl BytesLoader for EhttpLoader { + fn id(&self) -> &str { + Self::ID + } + fn load(&self, ctx: &egui::Context, uri: &str) -> BytesLoadResult { if !starts_with_one_of(uri, PROTOCOLS) { return Err(LoadError::NotSupported); @@ -59,15 +67,16 @@ impl BytesLoader for EhttpLoader { let mut cache = self.cache.lock(); if let Some(entry) = cache.get(uri).cloned() { match entry { - Poll::Ready(Ok(bytes)) => Ok(BytesPoll::Ready { + Poll::Ready(Ok(file)) => Ok(BytesPoll::Ready { size: None, - bytes: Bytes::Shared(bytes), + bytes: Bytes::Shared(file.bytes), + mime: file.mime, }), - Poll::Ready(Err(err)) => Err(LoadError::Custom(err)), + Poll::Ready(Err(err)) => Err(LoadError::Loading(err)), Poll::Pending => Ok(BytesPoll::Pending { size: None }), } } else { - crate::log_trace!("started loading {uri:?}"); + log::trace!("started loading {uri:?}"); let uri = uri.to_owned(); cache.insert(uri.clone(), Poll::Pending); @@ -77,8 +86,15 @@ impl BytesLoader for EhttpLoader { let ctx = ctx.clone(); let cache = self.cache.clone(); move |response| { - let result = get_image_bytes(&uri, response); - crate::log_trace!("finished loading {uri:?}"); + let result = match response { + Ok(response) => File::from_response(&uri, response), + Err(err) => { + // Log details; return summary + log::error!("Failed to load {uri:?}: {err}"); + Err(format!("Failed to load {uri:?}")) + } + }; + log::trace!("finished loading {uri:?}"); let prev = cache.lock().insert(uri, Poll::Ready(result)); assert!(matches!(prev, Some(Poll::Pending))); ctx.request_repaint(); @@ -93,12 +109,18 @@ impl BytesLoader for EhttpLoader { 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 { - Poll::Ready(Ok(bytes)) => bytes.len(), + Poll::Ready(Ok(file)) => { + file.bytes.len() + file.mime.as_ref().map_or(0, |m| m.len()) + } Poll::Ready(Err(err)) => err.len(), _ => 0, }) diff --git a/crates/egui_extras/src/loaders/file_loader.rs b/crates/egui_extras/src/loaders/file_loader.rs index 533c3dc95..e86cb42f3 100644 --- a/crates/egui_extras/src/loaders/file_loader.rs +++ b/crates/egui_extras/src/loaders/file_loader.rs @@ -5,7 +5,13 @@ use egui::{ }; use std::{sync::Arc, task::Poll, thread}; -type Entry = Poll, String>>; +#[derive(Clone)] +struct File { + bytes: Arc<[u8]>, + mime: Option, +} + +type Entry = Poll>; #[derive(Default)] pub struct FileLoader { @@ -13,9 +19,17 @@ pub struct FileLoader { cache: Arc>>, } +impl FileLoader { + pub const ID: &str = egui::generate_loader_id!(FileLoader); +} + const PROTOCOL: &str = "file://"; impl BytesLoader for FileLoader { + fn id(&self) -> &str { + Self::ID + } + fn load(&self, ctx: &egui::Context, uri: &str) -> BytesLoadResult { // File loader only supports the `file` protocol. let Some(path) = uri.strip_prefix(PROTOCOL) else { @@ -26,15 +40,16 @@ impl BytesLoader for FileLoader { if let Some(entry) = cache.get(path).cloned() { // `path` has either begun loading, is loaded, or has failed to load. match entry { - Poll::Ready(Ok(bytes)) => Ok(BytesPoll::Ready { + Poll::Ready(Ok(file)) => Ok(BytesPoll::Ready { size: None, - bytes: Bytes::Shared(bytes), + bytes: Bytes::Shared(file.bytes), + mime: file.mime, }), - Poll::Ready(Err(err)) => Err(LoadError::Custom(err)), + Poll::Ready(Err(err)) => Err(LoadError::Loading(err)), Poll::Pending => Ok(BytesPoll::Pending { size: None }), } } else { - crate::log_trace!("started loading {uri:?}"); + log::trace!("started loading {uri:?}"); // We need to load the file at `path`. // Set the file to `pending` until we finish loading it. @@ -48,16 +63,29 @@ impl BytesLoader for FileLoader { .spawn({ let ctx = ctx.clone(); let cache = self.cache.clone(); - let uri = uri.to_owned(); + let _uri = uri.to_owned(); move || { let result = match std::fs::read(&path) { - Ok(bytes) => Ok(bytes.into()), + Ok(bytes) => { + #[cfg(feature = "mime_guess")] + let mime = mime_guess::from_path(&path) + .first_raw() + .map(|v| v.to_owned()); + + #[cfg(not(feature = "mime_guess"))] + let mime = None; + + Ok(File { + bytes: bytes.into(), + mime, + }) + } Err(err) => Err(err.to_string()), }; let prev = cache.lock().insert(path, Poll::Ready(result)); assert!(matches!(prev, Some(Poll::Pending))); ctx.request_repaint(); - crate::log_trace!("finished loading {uri:?}"); + log::trace!("finished loading {_uri:?}"); } }) .expect("failed to spawn thread"); @@ -70,12 +98,18 @@ impl BytesLoader for FileLoader { 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 { - Poll::Ready(Ok(bytes)) => bytes.len(), + Poll::Ready(Ok(file)) => { + file.bytes.len() + file.mime.as_ref().map_or(0, |m| m.len()) + } Poll::Ready(Err(err)) => err.len(), _ => 0, }) diff --git a/crates/egui_extras/src/loaders/image_loader.rs b/crates/egui_extras/src/loaders/image_loader.rs index 2dbc1f8bd..c566e6549 100644 --- a/crates/egui_extras/src/loaders/image_loader.rs +++ b/crates/egui_extras/src/loaders/image_loader.rs @@ -13,18 +13,36 @@ pub struct ImageCrateLoader { cache: Mutex>, } -fn is_supported(uri: &str) -> bool { +impl ImageCrateLoader { + pub const ID: &str = egui::generate_loader_id!(ImageCrateLoader); +} + +fn is_supported_uri(uri: &str) -> bool { let Some(ext) = Path::new(uri).extension().and_then(|ext| ext.to_str()) else { // `true` because if there's no extension, assume that we support it - return true + return true; }; ext != "svg" } +fn is_unsupported_mime(mime: &str) -> bool { + mime.contains("svg") +} + impl ImageLoader for ImageCrateLoader { + fn id(&self) -> &str { + Self::ID + } + fn load(&self, ctx: &egui::Context, uri: &str, _: SizeHint) -> ImageLoadResult { - if !is_supported(uri) { + // three stages of guessing if we support loading the image: + // 1. URI extension + // 2. Mime from `BytesPoll::Ready` + // 3. image::guess_format + + // (1) + if !is_supported_uri(uri) { return Err(LoadError::NotSupported); } @@ -32,18 +50,25 @@ impl ImageLoader for ImageCrateLoader { if let Some(entry) = cache.get(uri).cloned() { match entry { Ok(image) => Ok(ImagePoll::Ready { image }), - Err(err) => Err(LoadError::Custom(err)), + Err(err) => Err(LoadError::Loading(err)), } } else { match ctx.try_load_bytes(uri) { - Ok(BytesPoll::Ready { bytes, .. }) => { - crate::log_trace!("started loading {uri:?}"); + Ok(BytesPoll::Ready { bytes, mime, .. }) => { + // (2 and 3) + if mime.as_deref().is_some_and(is_unsupported_mime) + || image::guess_format(&bytes).is_err() + { + return Err(LoadError::NotSupported); + } + + log::trace!("started loading {uri:?}"); let result = crate::image::load_image_bytes(&bytes).map(Arc::new); - crate::log_trace!("finished loading {uri:?}"); + log::trace!("finished loading {uri:?}"); cache.insert(uri.into(), result.clone()); match result { Ok(image) => Ok(ImagePoll::Ready { image }), - Err(err) => Err(LoadError::Custom(err)), + Err(err) => Err(LoadError::Loading(err)), } } Ok(BytesPoll::Pending { size }) => Ok(ImagePoll::Pending { size }), @@ -56,6 +81,10 @@ impl ImageLoader for ImageCrateLoader { let _ = self.cache.lock().remove(uri); } + fn forget_all(&self) { + self.cache.lock().clear(); + } + fn byte_size(&self) -> usize { self.cache .lock() @@ -74,11 +103,11 @@ mod tests { #[test] fn check_support() { - assert!(is_supported("https://test.png")); - assert!(is_supported("test.jpeg")); - assert!(is_supported("http://test.gif")); - assert!(is_supported("test.webp")); - assert!(is_supported("file://test")); - assert!(!is_supported("test.svg")); + assert!(is_supported_uri("https://test.png")); + assert!(is_supported_uri("test.jpeg")); + assert!(is_supported_uri("http://test.gif")); + assert!(is_supported_uri("test.webp")); + assert!(is_supported_uri("file://test")); + assert!(!is_supported_uri("test.svg")); } } diff --git a/crates/egui_extras/src/loaders/svg_loader.rs b/crates/egui_extras/src/loaders/svg_loader.rs index 71f233803..c5ac37b40 100644 --- a/crates/egui_extras/src/loaders/svg_loader.rs +++ b/crates/egui_extras/src/loaders/svg_loader.rs @@ -13,13 +13,23 @@ pub struct SvgLoader { cache: Mutex>, } +impl SvgLoader { + pub const ID: &str = egui::generate_loader_id!(SvgLoader); +} + fn is_supported(uri: &str) -> bool { - let Some(ext) = Path::new(uri).extension().and_then(|ext| ext.to_str()) else { return false }; + let Some(ext) = Path::new(uri).extension().and_then(|ext| ext.to_str()) else { + return false; + }; ext == "svg" } impl ImageLoader for SvgLoader { + fn id(&self) -> &str { + Self::ID + } + fn load(&self, ctx: &egui::Context, uri: &str, size_hint: SizeHint) -> ImageLoadResult { if !is_supported(uri) { return Err(LoadError::NotSupported); @@ -32,25 +42,25 @@ impl ImageLoader for SvgLoader { if let Some(entry) = cache.get(&(uri.clone(), size_hint)).cloned() { match entry { Ok(image) => Ok(ImagePoll::Ready { image }), - Err(err) => Err(LoadError::Custom(err)), + Err(err) => Err(LoadError::Loading(err)), } } else { match ctx.try_load_bytes(&uri) { Ok(BytesPoll::Ready { bytes, .. }) => { - crate::log_trace!("started loading {uri:?}"); + log::trace!("started loading {uri:?}"); let fit_to = match size_hint { - SizeHint::Original => usvg::FitTo::Original, + SizeHint::Scale(factor) => usvg::FitTo::Zoom(factor.into_inner()), SizeHint::Width(w) => usvg::FitTo::Width(w), SizeHint::Height(h) => usvg::FitTo::Height(h), SizeHint::Size(w, h) => usvg::FitTo::Size(w, h), }; let result = crate::image::load_svg_bytes_with_size(&bytes, fit_to).map(Arc::new); - crate::log_trace!("finished loading {uri:?}"); + log::trace!("finished loading {uri:?}"); cache.insert((uri, size_hint), result.clone()); match result { Ok(image) => Ok(ImagePoll::Ready { image }), - Err(err) => Err(LoadError::Custom(err)), + Err(err) => Err(LoadError::Loading(err)), } } Ok(BytesPoll::Pending { size }) => Ok(ImagePoll::Pending { size }), @@ -63,6 +73,10 @@ impl ImageLoader for SvgLoader { self.cache.lock().retain(|(u, _), _| u != uri); } + fn forget_all(&self) { + self.cache.lock().clear(); + } + fn byte_size(&self) -> usize { self.cache .lock() diff --git a/crates/egui_extras/src/sizing.rs b/crates/egui_extras/src/sizing.rs index c6ae9d6da..70a2ca4e6 100644 --- a/crates/egui_extras/src/sizing.rs +++ b/crates/egui_extras/src/sizing.rs @@ -153,7 +153,7 @@ impl From> for Sizing { #[test] fn test_sizing() { let sizing: Sizing = vec![].into(); - assert_eq!(sizing.to_lengths(50.0, 0.0), vec![]); + assert_eq!(sizing.to_lengths(50.0, 0.0), Vec::::new()); let sizing: Sizing = vec![Size::remainder().at_least(20.0), Size::remainder()].into(); assert_eq!(sizing.to_lengths(50.0, 0.0), vec![25.0, 25.0]); diff --git a/crates/egui_demo_lib/src/syntax_highlighting.rs b/crates/egui_extras/src/syntax_highlighting.rs similarity index 70% rename from crates/egui_demo_lib/src/syntax_highlighting.rs rename to crates/egui_extras/src/syntax_highlighting.rs index 50f1ff939..1bb81d13f 100644 --- a/crates/egui_demo_lib/src/syntax_highlighting.rs +++ b/crates/egui_extras/src/syntax_highlighting.rs @@ -1,12 +1,19 @@ +//! Syntax highlighting for code. +//! +//! Turn on the `syntect` feature for great syntax highlighting of any language. +//! Otherwise, a very simple fallback will be used, that works okish for C, C++, Rust, and Python. + use egui::text::LayoutJob; /// View some code with syntax highlighting and selection. -pub fn code_view_ui(ui: &mut egui::Ui, mut code: &str) { - let language = "rs"; - let theme = CodeTheme::from_memory(ui.ctx()); - +pub fn code_view_ui( + ui: &mut egui::Ui, + theme: &CodeTheme, + mut code: &str, + language: &str, +) -> egui::Response { let mut layouter = |ui: &egui::Ui, string: &str, _wrap_width: f32| { - let layout_job = highlight(ui.ctx(), &theme, string, language); + let layout_job = highlight(ui.ctx(), theme, string, language); // layout_job.wrap.max_width = wrap_width; // no wrapping ui.fonts(|f| f.layout_job(layout_job)) }; @@ -18,10 +25,12 @@ pub fn code_view_ui(ui: &mut egui::Ui, mut code: &str) { .desired_rows(1) .lock_focus(true) .layouter(&mut layouter), - ); + ) } -/// Memoized Code highlighting +/// Add syntax highlighting to a code string. +/// +/// The results are memoized, so you can call this every frame without performance penalty. pub fn highlight(ctx: &egui::Context, theme: &CodeTheme, code: &str, language: &str) -> LayoutJob { impl egui::util::cache::ComputerMut<(&CodeTheme, &str, &str), LayoutJob> for Highlighter { fn compute(&mut self, (theme, code, lang): (&CodeTheme, &str, &str)) -> LayoutJob { @@ -41,9 +50,7 @@ pub fn highlight(ctx: &egui::Context, theme: &CodeTheme, code: &str, language: & // ---------------------------------------------------------------------------- #[cfg(not(feature = "syntect"))] -#[derive(Clone, Copy, PartialEq)] -#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] -#[derive(enum_map::Enum)] +#[derive(Clone, Copy, PartialEq, serde::Deserialize, serde::Serialize, enum_map::Enum)] enum TokenType { Comment, Keyword, @@ -54,8 +61,7 @@ enum TokenType { } #[cfg(feature = "syntect")] -#[derive(Clone, Copy, Hash, PartialEq)] -#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +#[derive(Clone, Copy, Hash, PartialEq, serde::Deserialize, serde::Serialize)] enum SyntectTheme { Base16EightiesDark, Base16MochaDark, @@ -118,9 +124,9 @@ impl SyntectTheme { } } -#[derive(Clone, Hash, PartialEq)] -#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] -#[cfg_attr(feature = "serde", serde(default))] +/// A selected color theme. +#[derive(Clone, Hash, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(default)] pub struct CodeTheme { dark_mode: bool, @@ -138,6 +144,7 @@ impl Default for CodeTheme { } impl CodeTheme { + /// Selects either dark or light theme based on the given style. pub fn from_style(style: &egui::Style) -> Self { if style.visuals.dark_mode { Self::dark() @@ -146,6 +153,9 @@ impl CodeTheme { } } + /// Load code theme from egui memory. + /// + /// There is one dark and one light theme stored at any one time. pub fn from_memory(ctx: &egui::Context) -> Self { if ctx.style().visuals.dark_mode { ctx.data_mut(|d| { @@ -160,6 +170,9 @@ impl CodeTheme { } } + /// Store theme to egui memory. + /// + /// There is one dark and one light theme stored at any one time. pub fn store_in_memory(self, ctx: &egui::Context) { if self.dark_mode { ctx.data_mut(|d| d.insert_persisted(egui::Id::new("dark"), self)); @@ -185,6 +198,7 @@ impl CodeTheme { } } + /// Show UI for changing the color theme. pub fn ui(&mut self, ui: &mut egui::Ui) { egui::widgets::global_dark_light_mode_buttons(ui); @@ -231,6 +245,7 @@ impl CodeTheme { } } + /// Show UI for changing the color theme. pub fn ui(&mut self, ui: &mut egui::Ui) { ui.horizontal_top(|ui| { let selected_id = egui::Id::null(); @@ -306,6 +321,7 @@ struct Highlighter { #[cfg(feature = "syntect")] impl Default for Highlighter { fn default() -> Self { + crate::profile_function!(); Self { ps: syntect::parsing::SyntaxSet::load_defaults_newlines(), ts: syntect::highlighting::ThemeSet::load_defaults(), @@ -313,7 +329,6 @@ impl Default for Highlighter { } } -#[cfg(feature = "syntect")] impl Highlighter { #[allow(clippy::unused_self, clippy::unnecessary_wraps)] fn highlight(&self, theme: &CodeTheme, code: &str, lang: &str) -> LayoutJob { @@ -332,7 +347,10 @@ impl Highlighter { }) } + #[cfg(feature = "syntect")] fn highlight_impl(&self, theme: &CodeTheme, text: &str, language: &str) -> Option { + crate::profile_function!(); + use syntect::easy::HighlightLines; use syntect::highlighting::FontStyle; use syntect::util::LinesWithEndings; @@ -400,13 +418,24 @@ struct Highlighter {} #[cfg(not(feature = "syntect"))] impl Highlighter { #[allow(clippy::unused_self, clippy::unnecessary_wraps)] - fn highlight(&self, theme: &CodeTheme, mut text: &str, _language: &str) -> LayoutJob { + fn highlight_impl( + &self, + theme: &CodeTheme, + mut text: &str, + language: &str, + ) -> Option { + crate::profile_function!(); + + let language = Language::new(language)?; + // Extremely simple syntax highlighter for when we compile without syntect let mut job = LayoutJob::default(); while !text.is_empty() { - if text.starts_with("//") { + if language.double_slash_comments && text.starts_with("//") + || language.hash_comments && text.starts_with('#') + { let end = text.find('\n').unwrap_or(text.len()); job.append(&text[..end], 0.0, theme.formats[TokenType::Comment].clone()); text = &text[end..]; @@ -427,7 +456,7 @@ impl Highlighter { .find(|c: char| !c.is_ascii_alphanumeric()) .map_or_else(|| text.len(), |i| i + 1); let word = &text[..end]; - let tt = if is_keyword(word) { + let tt = if language.is_keyword(word) { TokenType::Keyword } else { TokenType::Literal @@ -457,50 +486,173 @@ impl Highlighter { } } - job + Some(job) } } #[cfg(not(feature = "syntect"))] -fn is_keyword(word: &str) -> bool { - matches!( - word, - "as" | "async" - | "await" - | "break" - | "const" - | "continue" - | "crate" - | "dyn" - | "else" - | "enum" - | "extern" - | "false" - | "fn" - | "for" - | "if" - | "impl" - | "in" - | "let" - | "loop" - | "match" - | "mod" - | "move" - | "mut" - | "pub" - | "ref" - | "return" - | "self" - | "Self" - | "static" - | "struct" - | "super" - | "trait" - | "true" - | "type" - | "unsafe" - | "use" - | "where" - | "while" - ) +struct Language { + /// `// comment` + double_slash_comments: bool, + + /// `# comment` + hash_comments: bool, + + keywords: std::collections::BTreeSet<&'static str>, +} + +#[cfg(not(feature = "syntect"))] +impl Language { + fn new(language: &str) -> Option { + match language.to_lowercase().as_str() { + "c" | "h" | "hpp" | "cpp" | "c++" => Some(Self::cpp()), + "py" | "python" => Some(Self::python()), + "rs" | "rust" => Some(Self::rust()), + _ => { + None // unsupported language + } + } + } + + fn is_keyword(&self, word: &str) -> bool { + self.keywords.contains(word) + } + + fn cpp() -> Self { + Self { + double_slash_comments: true, + hash_comments: false, + keywords: [ + "alignas", + "alignof", + "and_eq", + "and", + "asm", + "atomic_cancel", + "atomic_commit", + "atomic_noexcept", + "auto", + "bitand", + "bitor", + "bool", + "break", + "case", + "catch", + "char", + "char16_t", + "char32_t", + "char8_t", + "class", + "co_await", + "co_return", + "co_yield", + "compl", + "concept", + "const_cast", + "const", + "consteval", + "constexpr", + "constinit", + "continue", + "decltype", + "default", + "delete", + "do", + "double", + "dynamic_cast", + "else", + "enum", + "explicit", + "export", + "extern", + "false", + "float", + "for", + "friend", + "goto", + "if", + "inline", + "int", + "long", + "mutable", + "namespace", + "new", + "noexcept", + "not_eq", + "not", + "nullptr", + "operator", + "or_eq", + "or", + "private", + "protected", + "public", + "reflexpr", + "register", + "reinterpret_cast", + "requires", + "return", + "short", + "signed", + "sizeof", + "static_assert", + "static_cast", + "static", + "struct", + "switch", + "synchronized", + "template", + "this", + "thread_local", + "throw", + "true", + "try", + "typedef", + "typeid", + "typename", + "union", + "unsigned", + "using", + "virtual", + "void", + "volatile", + "wchar_t", + "while", + "xor_eq", + "xor", + ] + .into_iter() + .collect(), + } + } + + fn python() -> Self { + Self { + double_slash_comments: false, + hash_comments: true, + keywords: [ + "and", "as", "assert", "break", "class", "continue", "def", "del", "elif", "else", + "except", "False", "finally", "for", "from", "global", "if", "import", "in", "is", + "lambda", "None", "nonlocal", "not", "or", "pass", "raise", "return", "True", + "try", "while", "with", "yield", + ] + .into_iter() + .collect(), + } + } + + fn rust() -> Self { + Self { + double_slash_comments: true, + hash_comments: false, + keywords: [ + "as", "async", "await", "break", "const", "continue", "crate", "dyn", "else", + "enum", "extern", "false", "fn", "for", "if", "impl", "in", "let", "loop", "match", + "mod", "move", "mut", "pub", "ref", "return", "self", "Self", "static", "struct", + "super", "trait", "true", "type", "unsafe", "use", "where", "while", + ] + .into_iter() + .collect(), + } + } } diff --git a/crates/egui_extras/src/table.rs b/crates/egui_extras/src/table.rs index e30367db4..c687a7f1c 100644 --- a/crates/egui_extras/src/table.rs +++ b/crates/egui_extras/src/table.rs @@ -648,7 +648,9 @@ impl<'a> Table<'a> { // If the last column is 'remainder', then let it fill the remainder! let eps = 0.1; // just to avoid some rounding errors. *column_width = available_width - eps; - *column_width = column_width.at_least(max_used_widths[i]); + if !column.clip { + *column_width = column_width.at_least(max_used_widths[i]); + } *column_width = width_range.clamp(*column_width); break; } diff --git a/crates/egui_glium/examples/native_texture.rs b/crates/egui_glium/examples/native_texture.rs index 94977cd61..eb5a956f4 100644 --- a/crates/egui_glium/examples/native_texture.rs +++ b/crates/egui_glium/examples/native_texture.rs @@ -30,8 +30,7 @@ fn main() { egui::SidePanel::left("my_side_panel").show(egui_ctx, |ui| { if ui .add(egui::Button::image_and_text( - texture_id, - button_image_size, + (texture_id, button_image_size), "Quit", )) .clicked() diff --git a/crates/egui_glow/src/lib.rs b/crates/egui_glow/src/lib.rs index 18d8ff870..4af9e409c 100644 --- a/crates/egui_glow/src/lib.rs +++ b/crates/egui_glow/src/lib.rs @@ -112,22 +112,30 @@ pub fn check_for_gl_error_impl(gl: &glow::Context, file: &str, line: u32, contex // --------------------------------------------------------------------------- -/// Profiling macro for feature "puffin" -macro_rules! profile_function { - ($($arg: tt)*) => { - #[cfg(feature = "puffin")] - #[cfg(not(target_arch = "wasm32"))] - puffin::profile_function!($($arg)*); - }; -} -pub(crate) use profile_function; +mod profiling_scopes { + #![allow(unused_macros)] + #![allow(unused_imports)] -/// Profiling macro for feature "puffin" -macro_rules! profile_scope { - ($($arg: tt)*) => { - #[cfg(feature = "puffin")] - #[cfg(not(target_arch = "wasm32"))] - puffin::profile_scope!($($arg)*); - }; + /// Profiling macro for feature "puffin" + macro_rules! profile_function { + ($($arg: tt)*) => { + #[cfg(feature = "puffin")] + #[cfg(not(target_arch = "wasm32"))] // Disabled on web because of the coarse 1ms clock resolution there. + puffin::profile_function!($($arg)*); + }; + } + pub(crate) use profile_function; + + /// Profiling macro for feature "puffin" + macro_rules! profile_scope { + ($($arg: tt)*) => { + #[cfg(feature = "puffin")] + #[cfg(not(target_arch = "wasm32"))] // Disabled on web because of the coarse 1ms clock resolution there. + puffin::profile_scope!($($arg)*); + }; + } + pub(crate) use profile_scope; } -pub(crate) use profile_scope; + +#[allow(unused_imports)] +pub(crate) use profiling_scopes::*; diff --git a/crates/egui_glow/src/painter.rs b/crates/egui_glow/src/painter.rs index e6ac79eca..c04dd927e 100644 --- a/crates/egui_glow/src/painter.rs +++ b/crates/egui_glow/src/painter.rs @@ -370,25 +370,6 @@ impl Painter { Primitive::Callback(callback) => { if callback.rect.is_positive() { crate::profile_scope!("callback"); - // Transform callback rect to physical pixels: - let rect_min_x = pixels_per_point * callback.rect.min.x; - let rect_min_y = pixels_per_point * callback.rect.min.y; - let rect_max_x = pixels_per_point * callback.rect.max.x; - let rect_max_y = pixels_per_point * callback.rect.max.y; - - let rect_min_x = rect_min_x.round() as i32; - let rect_min_y = rect_min_y.round() as i32; - let rect_max_x = rect_max_x.round() as i32; - let rect_max_y = rect_max_y.round() as i32; - - unsafe { - self.gl.viewport( - rect_min_x, - size_in_pixels.1 as i32 - rect_max_y, - rect_max_x - rect_min_x, - rect_max_y - rect_min_y, - ); - } let info = egui::PaintCallbackInfo { viewport: callback.rect, @@ -397,6 +378,16 @@ impl Painter { screen_size_px, }; + let viewport_px = info.viewport_in_pixels(); + unsafe { + self.gl.viewport( + viewport_px.left_px.round() as _, + viewport_px.from_bottom_px.round() as _, + viewport_px.width_px.round() as _, + viewport_px.height_px.round() as _, + ); + } + if let Some(callback) = callback.callback.downcast_ref::() { (callback.f)(info, self); } else { @@ -494,10 +485,13 @@ impl Painter { "Mismatch between texture size and texel count" ); - let data: Vec = image - .srgba_pixels(None) - .flat_map(|a| a.to_array()) - .collect(); + let data: Vec = { + crate::profile_scope!("font -> sRGBA"); + image + .srgba_pixels(None) + .flat_map(|a| a.to_array()) + .collect() + }; self.upload_texture_srgb(delta.pos, image.size, delta.options, &data); } @@ -511,6 +505,7 @@ impl Painter { options: egui::TextureOptions, data: &[u8], ) { + crate::profile_function!(); assert_eq!(data.len(), w * h * 4); assert!( w <= self.max_texture_side && h <= self.max_texture_side, @@ -561,6 +556,7 @@ impl Painter { let level = 0; if let Some([x, y]) = pos { + crate::profile_scope!("gl.tex_sub_image_2d"); self.gl.tex_sub_image_2d( glow::TEXTURE_2D, level, @@ -575,6 +571,7 @@ impl Painter { check_for_gl_error!(&self.gl, "tex_sub_image_2d"); } else { let border = 0; + crate::profile_scope!("gl.tex_image_2d"); self.gl.tex_image_2d( glow::TEXTURE_2D, level, diff --git a/crates/egui_plot/src/items/mod.rs b/crates/egui_plot/src/items/mod.rs index 820850f42..644a2c14e 100644 --- a/crates/egui_plot/src/items/mod.rs +++ b/crates/egui_plot/src/items/mod.rs @@ -1234,12 +1234,19 @@ impl PlotItem for PlotImage { Rect::from_two_pos(left_top_screen, right_bottom_screen) }; let screen_rotation = -*rotation as f32; - Image::new(*texture_id, image_screen_rect.size()) - .bg_fill(*bg_fill) - .tint(*tint) - .uv(*uv) - .rotate(screen_rotation, Vec2::splat(0.5)) - .paint_at(ui, image_screen_rect); + + egui::paint_texture_at( + ui.painter(), + image_screen_rect, + &ImageOptions { + uv: *uv, + bg_fill: *bg_fill, + tint: *tint, + rotation: Some((Rot2::from_angle(screen_rotation), Vec2::splat(0.5))), + rounding: Rounding::ZERO, + }, + &(*texture_id, image_screen_rect.size()).into(), + ); if *highlight { let center = image_screen_rect.center(); let rotation = Rot2::from_angle(screen_rotation); diff --git a/crates/epaint/src/image.rs b/crates/epaint/src/image.rs index dca86f38c..0a40fe4a0 100644 --- a/crates/epaint/src/image.rs +++ b/crates/epaint/src/image.rs @@ -280,6 +280,7 @@ impl FontImage { /// `gamma` should normally be set to `None`. /// /// If you are having problems with text looking skinny and pixelated, try using a low gamma, e.g. `0.4`. + #[inline] pub fn srgba_pixels( &'_ self, gamma: Option, @@ -338,6 +339,7 @@ impl From for ImageData { } } +#[inline] fn fast_round(r: f32) -> u8 { (r + 0.5).floor() as _ // rust does a saturating cast since 1.45 } diff --git a/crates/epaint/src/shape.rs b/crates/epaint/src/shape.rs index 8854a2e86..718da636b 100644 --- a/crates/epaint/src/shape.rs +++ b/crates/epaint/src/shape.rs @@ -787,6 +787,8 @@ pub struct PaintCallbackInfo { /// Rect is the [-1, +1] of the Normalized Device Coordinates. /// /// Note than only a portion of this may be visible due to [`Self::clip_rect`]. + /// + /// This comes from [`PaintCallback::rect`]. pub viewport: Rect, /// Clip rectangle in points. @@ -819,7 +821,7 @@ pub struct ViewportInPixels { } impl PaintCallbackInfo { - fn points_to_pixels(&self, rect: &Rect) -> ViewportInPixels { + fn pixels_from_points(&self, rect: &Rect) -> ViewportInPixels { ViewportInPixels { left_px: rect.min.x * self.pixels_per_point, top_px: rect.min.y * self.pixels_per_point, @@ -831,12 +833,12 @@ impl PaintCallbackInfo { /// The viewport rectangle. This is what you would use in e.g. `glViewport`. pub fn viewport_in_pixels(&self) -> ViewportInPixels { - self.points_to_pixels(&self.viewport) + self.pixels_from_points(&self.viewport) } /// The "scissor" or "clip" rectangle. This is what you would use in e.g. `glScissor`. pub fn clip_rect_in_pixels(&self) -> ViewportInPixels { - self.points_to_pixels(&self.clip_rect) + self.pixels_from_points(&self.clip_rect) } } @@ -846,6 +848,8 @@ impl PaintCallbackInfo { #[derive(Clone)] pub struct PaintCallback { /// Where to paint. + /// + /// This will become [`PaintCallbackInfo::viewport`]. pub rect: Rect, /// Paint something custom (e.g. 3D stuff). diff --git a/crates/epaint/src/tessellator.rs b/crates/epaint/src/tessellator.rs index 44300cab2..49cbd3119 100644 --- a/crates/epaint/src/tessellator.rs +++ b/crates/epaint/src/tessellator.rs @@ -1063,7 +1063,7 @@ fn mul_color(color: Color32, factor: f32) -> Color32 { /// /// For performance reasons it is smart to reuse the same [`Tessellator`]. /// -/// Se also [`tessellate_shapes`], a convenient wrapper around [`Tessellator`]. +/// See also [`tessellate_shapes`], a convenient wrapper around [`Tessellator`]. pub struct Tessellator { pixels_per_point: f32, options: TessellationOptions, diff --git a/crates/epaint/src/text/font.rs b/crates/epaint/src/text/font.rs index e6b495c23..163697d5f 100644 --- a/crates/epaint/src/text/font.rs +++ b/crates/epaint/src/text/font.rs @@ -43,12 +43,6 @@ pub struct GlyphInfo { /// Unit: points. pub advance_width: f32, - /// `ascent` value from the font metrics. - /// this is the distance from the top to the baseline. - /// - /// Unit: points. - pub ascent: f32, - /// Texture coordinates. pub uv_rect: UvRect, } @@ -59,7 +53,6 @@ impl Default for GlyphInfo { Self { id: ab_glyph::GlyphId(0), advance_width: 0.0, - ascent: 0.0, uv_rect: Default::default(), } } @@ -188,7 +181,7 @@ impl FontImpl { if let Some(space) = self.glyph_info(' ') { let glyph_info = GlyphInfo { advance_width: crate::text::TAB_SIZE as f32 * space.advance_width, - ..GlyphInfo::default() + ..space }; self.glyph_info_cache.write().insert(c, glyph_info); return Some(glyph_info); @@ -205,7 +198,7 @@ impl FontImpl { let advance_width = f32::min(em / 6.0, space.advance_width * 0.5); let glyph_info = GlyphInfo { advance_width, - ..GlyphInfo::default() + ..space }; self.glyph_info_cache.write().insert(c, glyph_info); return Some(glyph_info); @@ -255,6 +248,14 @@ impl FontImpl { self.pixels_per_point } + /// This is the distance from the top to the baseline. + /// + /// Unit: points. + #[inline(always)] + pub fn ascent(&self) -> f32 { + self.ascent + } + fn allocate_glyph(&self, glyph_id: ab_glyph::GlyphId) -> GlyphInfo { assert!(glyph_id.0 != 0); use ab_glyph::{Font as _, ScaleFont}; @@ -305,7 +306,6 @@ impl FontImpl { GlyphInfo { id: glyph_id, advance_width: advance_width_in_points, - ascent: self.ascent, uv_rect, } } @@ -442,7 +442,7 @@ impl Font { } #[inline] - pub(crate) fn glyph_info_and_font_impl(&mut self, c: char) -> (Option<&FontImpl>, GlyphInfo) { + pub(crate) fn font_impl_and_glyph_info(&mut self, c: char) -> (Option<&FontImpl>, GlyphInfo) { if self.fonts.is_empty() { return (None, self.replacement_glyph.1); } diff --git a/crates/epaint/src/text/text_layout.rs b/crates/epaint/src/text/text_layout.rs index 4bd101bf0..860c19f8f 100644 --- a/crates/epaint/src/text/text_layout.rs +++ b/crates/epaint/src/text/text_layout.rs @@ -134,7 +134,7 @@ fn layout_section( paragraph = out_paragraphs.last_mut().unwrap(); paragraph.empty_paragraph_height = line_height; // TODO(emilk): replace this hack with actually including `\n` in the glyphs? } else { - let (font_impl, glyph_info) = font.glyph_info_and_font_impl(chr); + let (font_impl, glyph_info) = font.font_impl_and_glyph_info(chr); if let Some(font_impl) = font_impl { if let Some(last_glyph_id) = last_glyph_id { paragraph.cursor_x += font_impl.pair_kerning(last_glyph_id, glyph_info.id); @@ -146,7 +146,7 @@ fn layout_section( chr, pos: pos2(paragraph.cursor_x, f32::NAN), size: vec2(glyph_info.advance_width, line_height), - ascent: glyph_info.ascent, + ascent: font_impl.map_or(0.0, |font| font.ascent()), // Failure to find the font here would be weird uv_rect: glyph_info.uv_rect, section_index, }); @@ -340,12 +340,12 @@ fn replace_last_glyph_with_overflow_character( .unwrap_or_else(|| font.row_height()); let prev_glyph_id = prev_glyph.map(|prev_glyph| { - let (_, prev_glyph_info) = font.glyph_info_and_font_impl(prev_glyph.chr); + let (_, prev_glyph_info) = font.font_impl_and_glyph_info(prev_glyph.chr); prev_glyph_info.id }); // undo kerning with previous glyph - let (font_impl, glyph_info) = font.glyph_info_and_font_impl(last_glyph.chr); + let (font_impl, glyph_info) = font.font_impl_and_glyph_info(last_glyph.chr); last_glyph.pos.x -= extra_letter_spacing + font_impl .zip(prev_glyph_id) @@ -356,10 +356,9 @@ fn replace_last_glyph_with_overflow_character( // replace the glyph last_glyph.chr = overflow_character; - let (font_impl, glyph_info) = font.glyph_info_and_font_impl(last_glyph.chr); + let (font_impl, glyph_info) = font.font_impl_and_glyph_info(last_glyph.chr); last_glyph.size = vec2(glyph_info.advance_width, line_height); last_glyph.uv_rect = glyph_info.uv_rect; - last_glyph.ascent = glyph_info.ascent; // reapply kerning last_glyph.pos.x += extra_letter_spacing diff --git a/crates/epaint/src/texture_handle.rs b/crates/epaint/src/texture_handle.rs index 24d7179b9..f4142d915 100644 --- a/crates/epaint/src/texture_handle.rs +++ b/crates/epaint/src/texture_handle.rs @@ -86,7 +86,10 @@ impl TextureHandle { /// width x height pub fn size(&self) -> [usize; 2] { - self.tex_mngr.read().meta(self.id).unwrap().size + self.tex_mngr + .read() + .meta(self.id) + .map_or([0, 0], |tex| tex.size) } /// width x height @@ -97,7 +100,10 @@ impl TextureHandle { /// `width x height x bytes_per_pixel` pub fn byte_size(&self) -> usize { - self.tex_mngr.read().meta(self.id).unwrap().bytes_used() + self.tex_mngr + .read() + .meta(self.id) + .map_or(0, |tex| tex.bytes_used()) } /// width / height @@ -108,7 +114,10 @@ impl TextureHandle { /// Debug-name. pub fn name(&self) -> String { - self.tex_mngr.read().meta(self.id).unwrap().name.clone() + self.tex_mngr + .read() + .meta(self.id) + .map_or_else(|| "".to_owned(), |tex| tex.name.clone()) } } diff --git a/crates/epaint/src/util/ordered_float.rs b/crates/epaint/src/util/ordered_float.rs index 1bef308cd..0b305ca12 100644 --- a/crates/epaint/src/util/ordered_float.rs +++ b/crates/epaint/src/util/ordered_float.rs @@ -8,8 +8,16 @@ use std::hash::{Hash, Hasher}; /// Possible types for `T` are `f32` and `f64`. /// /// See also [`FloatOrd`]. +#[derive(Clone, Copy)] pub struct OrderedFloat(T); +impl OrderedFloat { + #[inline] + pub fn into_inner(self) -> T { + self.0 + } +} + impl Eq for OrderedFloat {} impl PartialEq for OrderedFloat { diff --git a/examples/download_image/Cargo.toml b/examples/download_image/Cargo.toml deleted file mode 100644 index 630b7ef68..000000000 --- a/examples/download_image/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "download_image" -version = "0.1.0" -authors = ["Emil Ernerfeldt "] -license = "MIT OR Apache-2.0" -edition = "2021" -rust-version = "1.70" -publish = false - - -[dependencies] -eframe = { path = "../../crates/eframe", features = [ - "__screenshot", # __screenshot is so we can dump a screenshot using EFRAME_SCREENSHOT_TO -] } -egui_extras = { path = "../../crates/egui_extras", features = [ - "http", - "image", - "log", -] } -env_logger = "0.10" -image = { version = "0.24", default-features = false, features = ["jpeg"] } diff --git a/examples/download_image/README.md b/examples/download_image/README.md deleted file mode 100644 index 936e1e058..000000000 --- a/examples/download_image/README.md +++ /dev/null @@ -1,7 +0,0 @@ -Example how to download and show an image with eframe/egui. - -```sh -cargo run -p download_image -``` - -![](screenshot.png) diff --git a/examples/download_image/screenshot.png b/examples/download_image/screenshot.png deleted file mode 100644 index 919c4544f..000000000 Binary files a/examples/download_image/screenshot.png and /dev/null differ diff --git a/examples/download_image/src/main.rs b/examples/download_image/src/main.rs deleted file mode 100644 index 610c39d1f..000000000 --- a/examples/download_image/src/main.rs +++ /dev/null @@ -1,41 +0,0 @@ -#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release - -use eframe::egui; - -fn main() -> Result<(), eframe::Error> { - env_logger::init(); // Log to stderr (if you run with `RUST_LOG=debug`). - let options = eframe::NativeOptions::default(); - eframe::run_native( - "Download and show an image with eframe/egui", - options, - Box::new(|cc| { - // Without the following call, the `Image2` created below - // will simply output `not supported` error messages. - egui_extras::loaders::install(&cc.egui_ctx); - Box::new(MyApp) - }), - ) -} - -#[derive(Default)] -struct MyApp; - -impl eframe::App for MyApp { - fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - egui::CentralPanel::default().show(ctx, |ui| { - let width = ui.available_width(); - let half_height = ui.available_height() / 2.0; - - ui.allocate_ui(egui::Vec2::new(width, half_height), |ui| { - ui.add(egui::Image2::from_uri( - "https://picsum.photos/seed/1.759706314/1024", - )) - }); - ui.allocate_ui(egui::Vec2::new(width, half_height), |ui| { - ui.add(egui::Image2::from_uri( - "https://this-is-hopefully-not-a-real-website.rs/image.png", - )) - }); - }); - } -} diff --git a/examples/retained_image/Cargo.toml b/examples/images/Cargo.toml similarity index 62% rename from examples/retained_image/Cargo.toml rename to examples/images/Cargo.toml index 50cf0e5bb..851fa8f79 100644 --- a/examples/retained_image/Cargo.toml +++ b/examples/images/Cargo.toml @@ -1,7 +1,7 @@ [package] -name = "retained_image" +name = "images" version = "0.1.0" -authors = ["Emil Ernerfeldt "] +authors = ["Jan Procházka "] license = "MIT OR Apache-2.0" edition = "2021" rust-version = "1.70" @@ -10,8 +10,11 @@ publish = false [dependencies] eframe = { path = "../../crates/eframe", features = [ - "__screenshot", # __screenshot is so we can dump a screenshot using EFRAME_SCREENSHOT_TO + "__screenshot", # __screenshot is so we can dump a screenshot using EFRAME_SCREENSHOT_TO ] } -egui_extras = { path = "../../crates/egui_extras", features = ["image", "log"] } +egui_extras = { path = "../../crates/egui_extras", features = ["all_loaders"] } env_logger = "0.10" -image = { version = "0.24", default-features = false, features = ["png"] } +image = { version = "0.24", default-features = false, features = [ + "jpeg", + "png", +] } diff --git a/examples/images/README.md b/examples/images/README.md new file mode 100644 index 000000000..02f5007a6 --- /dev/null +++ b/examples/images/README.md @@ -0,0 +1,7 @@ +Example showing how to show images with eframe/egui. + +```sh +cargo run -p images +``` + +![](screenshot.png) diff --git a/examples/images/screenshot.png b/examples/images/screenshot.png new file mode 100644 index 000000000..b9d550b32 Binary files /dev/null and b/examples/images/screenshot.png differ diff --git a/examples/svg/src/rustacean-flat-happy.svg b/examples/images/src/ferris.svg similarity index 100% rename from examples/svg/src/rustacean-flat-happy.svg rename to examples/images/src/ferris.svg diff --git a/examples/images/src/main.rs b/examples/images/src/main.rs new file mode 100644 index 000000000..87a680d1e --- /dev/null +++ b/examples/images/src/main.rs @@ -0,0 +1,38 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release + +use eframe::egui; + +fn main() -> Result<(), eframe::Error> { + env_logger::init(); // Log to stderr (if you run with `RUST_LOG=debug`). + let options = eframe::NativeOptions { + initial_window_size: Some(egui::vec2(600.0, 800.0)), + ..Default::default() + }; + eframe::run_native( + "Image Viewer", + options, + Box::new(|cc| { + // The following call is needed to load images when using `ui.image` and `egui::Image`: + egui_extras::install_image_loaders(&cc.egui_ctx); + Box::::default() + }), + ) +} + +#[derive(Default)] +struct MyApp {} + +impl eframe::App for MyApp { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + egui::CentralPanel::default().show(ctx, |ui| { + egui::ScrollArea::new([true, true]).show(ui, |ui| { + ui.image(egui::include_image!("ferris.svg")); + + ui.add( + egui::Image::new("https://picsum.photos/seed/1.759706314/1024") + .rounding(egui::Rounding::same(10.0)), + ); + }); + }); + } +} diff --git a/examples/puffin_profiler/src/main.rs b/examples/puffin_profiler/src/main.rs index f8d52e91c..ab0007156 100644 --- a/examples/puffin_profiler/src/main.rs +++ b/examples/puffin_profiler/src/main.rs @@ -56,6 +56,12 @@ fn start_puffin_server() { Ok(puffin_server) => { eprintln!("Run: cargo install puffin_viewer && puffin_viewer --url 127.0.0.1:8585"); + std::process::Command::new("puffin_viewer") + .arg("--url") + .arg("127.0.0.1:8585") + .spawn() + .ok(); + // We can store the server if we want, but in this case we just want // it to keep running. Dropping it closes the server, so let's not drop it! #[allow(clippy::mem_forget)] diff --git a/examples/retained_image/README.md b/examples/retained_image/README.md deleted file mode 100644 index 122a7481a..000000000 --- a/examples/retained_image/README.md +++ /dev/null @@ -1,7 +0,0 @@ -Example how to show an image with eframe/egui. - -```sh -cargo run -p retained_image -``` - -![](screenshot.png) diff --git a/examples/retained_image/screenshot.png b/examples/retained_image/screenshot.png deleted file mode 100644 index 9e2234e48..000000000 Binary files a/examples/retained_image/screenshot.png and /dev/null differ diff --git a/examples/retained_image/src/crab.png b/examples/retained_image/src/crab.png deleted file mode 100644 index 781b21993..000000000 Binary files a/examples/retained_image/src/crab.png and /dev/null differ diff --git a/examples/retained_image/src/main.rs b/examples/retained_image/src/main.rs deleted file mode 100644 index f96fb9642..000000000 --- a/examples/retained_image/src/main.rs +++ /dev/null @@ -1,84 +0,0 @@ -#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release - -use eframe::egui; -use egui_extras::RetainedImage; - -fn main() -> Result<(), eframe::Error> { - env_logger::init(); // Log to stderr (if you run with `RUST_LOG=debug`). - let options = eframe::NativeOptions { - initial_window_size: Some(egui::vec2(400.0, 1000.0)), - ..Default::default() - }; - eframe::run_native( - "Show an image with eframe/egui", - options, - Box::new(|_cc| Box::::default()), - ) -} - -struct MyApp { - image: RetainedImage, - rounding: f32, - tint: egui::Color32, -} - -impl Default for MyApp { - fn default() -> Self { - Self { - // crab image is CC0, found on https://stocksnap.io/search/crab - image: RetainedImage::from_image_bytes("crab.png", include_bytes!("crab.png")).unwrap(), - rounding: 32.0, - tint: egui::Color32::from_rgb(100, 200, 200), - } - } -} - -impl eframe::App for MyApp { - fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - let Self { - image, - rounding, - tint, - } = self; - - egui::CentralPanel::default().show(ctx, |ui| { - ui.heading("This is an image:"); - image.show(ui); - - ui.add_space(32.0); - - ui.heading("This is a tinted image with rounded corners:"); - ui.add( - egui::Image::new(image.texture_id(ctx), image.size_vec2()) - .tint(*tint) - .rounding(*rounding), - ); - - ui.horizontal(|ui| { - ui.label("Tint:"); - egui::color_picker::color_edit_button_srgba( - ui, - tint, - egui::color_picker::Alpha::BlendOrAdditive, - ); - - ui.add_space(16.0); - - ui.label("Rounding:"); - ui.add( - egui::DragValue::new(rounding) - .speed(1.0) - .clamp_range(0.0..=0.5 * image.size_vec2().min_elem()), - ); - }); - - ui.add_space(32.0); - - ui.heading("This is an image you can click:"); - ui.add(egui::ImageButton::new( - image.texture_id(ctx), - image.size_vec2(), - )); - }); - } -} diff --git a/examples/screenshot/src/main.rs b/examples/screenshot/src/main.rs index 4dff5326d..498d5013e 100644 --- a/examples/screenshot/src/main.rs +++ b/examples/screenshot/src/main.rs @@ -63,7 +63,7 @@ impl eframe::App for MyApp { }); if let Some(texture) = self.texture.as_ref() { - ui.image(texture, ui.available_size()); + ui.image((texture.id(), ui.available_size())); } else { ui.spinner(); } diff --git a/examples/svg/Cargo.toml b/examples/svg/Cargo.toml deleted file mode 100644 index 62333a09a..000000000 --- a/examples/svg/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "svg" -version = "0.1.0" -authors = ["Emil Ernerfeldt "] -license = "MIT OR Apache-2.0" -edition = "2021" -rust-version = "1.70" -publish = false - - -[dependencies] -eframe = { path = "../../crates/eframe", features = [ - "__screenshot", # __screenshot is so we can dump a screenshot using EFRAME_SCREENSHOT_TO -] } -egui_extras = { path = "../../crates/egui_extras", features = ["log", "svg"] } -env_logger = "0.10" diff --git a/examples/svg/README.md b/examples/svg/README.md deleted file mode 100644 index c171c1724..000000000 --- a/examples/svg/README.md +++ /dev/null @@ -1,7 +0,0 @@ -Example how to show an SVG image. - -```sh -cargo run -p svg -``` - -![](screenshot.png) diff --git a/examples/svg/screenshot.png b/examples/svg/screenshot.png deleted file mode 100644 index 1a9664a82..000000000 Binary files a/examples/svg/screenshot.png and /dev/null differ diff --git a/examples/svg/src/main.rs b/examples/svg/src/main.rs deleted file mode 100644 index bba91d059..000000000 --- a/examples/svg/src/main.rs +++ /dev/null @@ -1,47 +0,0 @@ -//! A good way of displaying an SVG image in egui. -//! -//! Requires the dependency `egui_extras` with the `svg` feature. - -#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release - -use eframe::egui; - -fn main() -> Result<(), eframe::Error> { - env_logger::init(); // Log to stderr (if you run with `RUST_LOG=debug`). - let options = eframe::NativeOptions { - initial_window_size: Some(egui::vec2(1000.0, 700.0)), - ..Default::default() - }; - eframe::run_native( - "svg example", - options, - Box::new(|cc| { - // Without the following call, the `Image2` created below - // will simply output a `not supported` error message. - egui_extras::loaders::install(&cc.egui_ctx); - Box::new(MyApp) - }), - ) -} - -struct MyApp; - -impl eframe::App for MyApp { - fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - egui::CentralPanel::default().show(ctx, |ui| { - ui.heading("SVG example"); - ui.label("The SVG is rasterized and displayed as a texture."); - - ui.separator(); - - let max_size = ui.available_size(); - ui.add( - egui::Image2::from_static_bytes( - "ferris.svg", - include_bytes!("rustacean-flat-happy.svg"), - ) - .size_hint(max_size), - ); - }); - } -} diff --git a/examples/svg/src/rust-logo-license.txt b/examples/svg/src/rust-logo-license.txt deleted file mode 100644 index 7efaf7593..000000000 --- a/examples/svg/src/rust-logo-license.txt +++ /dev/null @@ -1 +0,0 @@ -Rust logo by Mozilla, from https://github.com/rust-lang/rust-artwork