diff --git a/Cargo.lock b/Cargo.lock index f3dc1a9c..303a7a29 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -697,6 +697,22 @@ dependencies = [ "winapi", ] +[[package]] +name = "cocoa" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f425db7937052c684daec3bd6375c8abe2d146dca4b8b143d6db777c39138f3a" +dependencies = [ + "bitflags", + "block", + "cocoa-foundation", + "core-foundation", + "core-graphics", + "foreign-types", + "libc", + "objc", +] + [[package]] name = "cocoa-foundation" version = "0.1.0" @@ -1182,6 +1198,7 @@ name = "eframe" version = "0.21.3" dependencies = [ "bytemuck", + "cocoa", "directories-next", "document-features", "egui", @@ -1194,6 +1211,7 @@ dependencies = [ "image", "js-sys", "log", + "objc", "percent-encoding", "pollster", "puffin", @@ -1206,6 +1224,7 @@ dependencies = [ "wasm-bindgen-futures", "web-sys", "wgpu", + "winapi", "winit", ] diff --git a/crates/eframe/Cargo.toml b/crates/eframe/Cargo.toml index 275c0565..c8b7da9e 100644 --- a/crates/eframe/Cargo.toml +++ b/crates/eframe/Cargo.toml @@ -58,7 +58,7 @@ web_screen_reader = ["tts"] ## If set, eframe will look for the env-var `EFRAME_SCREENSHOT_TO` and write a screenshot to that location, and then quit. ## This is used to generate images for the examples. -__screenshot = ["dep:image"] +__screenshot = [] ## Use [`wgpu`](https://docs.rs/wgpu) for painting (via [`egui-wgpu`](https://github.com/emilk/egui/tree/master/crates/egui-wgpu)). ## This overrides the `glow` feature. @@ -98,6 +98,9 @@ egui-winit = { version = "0.21.1", path = "../egui-winit", default-features = fa "clipboard", "links", ] } +image = { version = "0.24", default-features = false, features = [ + "png", +] } # Needed for app icon raw-window-handle = { version = "0.5.0" } winit = "0.28.1" @@ -112,12 +115,18 @@ pollster = { version = "0.3", optional = true } # needed for wgpu # this can be done at the same time we expose x11/wayland features of winit crate. glutin = { version = "0.30", optional = true } glutin-winit = { version = "0.3.0", optional = true } -image = { version = "0.24", optional = true, default-features = false, features = [ - "png", -] } puffin = { version = "0.14", optional = true } wgpu = { version = "0.15.0", optional = true } +# mac: +[target.'cfg(any(target_os = "macos"))'.dependencies] +cocoa = "0.24.1" +objc = "0.2.7" + +# windows: +[target.'cfg(any(target_os = "windows"))'.dependencies] +winapi = "0.3.9" + # ------------------------------------------- # web: [target.'cfg(target_arch = "wasm32")'.dependencies] diff --git a/crates/eframe/src/epi/icon_data.rs b/crates/eframe/src/epi/icon_data.rs new file mode 100644 index 00000000..2bb6b138 --- /dev/null +++ b/crates/eframe/src/epi/icon_data.rs @@ -0,0 +1,64 @@ +/// Image data for an application icon. +/// +/// Use a square image, e.g. 256x256 pixels. +/// You can use a transparent background. +#[derive(Clone)] +pub struct IconData { + /// RGBA pixels, with separate/unmultiplied alpha. + pub rgba: Vec, + + /// Image width. This should be a multiple of 4. + pub width: u32, + + /// Image height. This should be a multiple of 4. + pub height: u32, +} + +impl IconData { + /// Convert into [`image::RgbaImage`] + /// + /// # Errors + /// If this is not a valid png. + pub fn try_from_png_bytes(png_bytes: &[u8]) -> Result { + let image = image::load_from_memory(png_bytes)?; + Ok(Self::from_image(image)) + } + + fn from_image(image: image::DynamicImage) -> Self { + let image = image.into_rgba8(); + Self { + width: image.width(), + height: image.height(), + rgba: image.into_raw(), + } + } + + /// Convert into [`image::RgbaImage`] + /// + /// # Errors + /// If `width*height != 4 * rgba.len()`, or if the image is too big. + pub fn to_image(&self) -> Result { + let Self { + rgba, + width, + height, + } = self.clone(); + image::RgbaImage::from_raw(width, height, rgba).ok_or_else(|| "Invalid IconData".to_owned()) + } + + /// Encode as PNG. + /// + /// # Errors + /// The image is invalid, or the PNG encoder failed. + pub fn to_png_bytes(&self) -> Result, String> { + let image = self.to_image()?; + let mut png_bytes: Vec = Vec::new(); + image + .write_to( + &mut std::io::Cursor::new(&mut png_bytes), + image::ImageOutputFormat::Png, + ) + .map_err(|err| err.to_string())?; + Ok(png_bytes) + } +} diff --git a/crates/eframe/src/epi.rs b/crates/eframe/src/epi/mod.rs similarity index 99% rename from crates/eframe/src/epi.rs rename to crates/eframe/src/epi/mod.rs index 33166362..33ef0f75 100644 --- a/crates/eframe/src/epi.rs +++ b/crates/eframe/src/epi/mod.rs @@ -6,6 +6,12 @@ #![warn(missing_docs)] // Let's keep `epi` well-documented. +#[cfg(not(target_arch = "wasm32"))] +mod icon_data; + +#[cfg(not(target_arch = "wasm32"))] +pub use icon_data::IconData; + #[cfg(target_arch = "wasm32")] use std::any::Any; @@ -621,19 +627,6 @@ impl std::str::FromStr for Renderer { // ---------------------------------------------------------------------------- -/// Image data for an application icon. -#[derive(Clone)] -pub struct IconData { - /// RGBA pixels, unmultiplied. - pub rgba: Vec, - - /// Image width. This should be a multiple of 4. - pub width: u32, - - /// Image height. This should be a multiple of 4. - pub height: u32, -} - /// Represents the surroundings of your app. /// /// It provides methods to inspect the surroundings (are we on the web?), diff --git a/crates/eframe/src/native/app_icon.rs b/crates/eframe/src/native/app_icon.rs new file mode 100644 index 00000000..20a49a6e --- /dev/null +++ b/crates/eframe/src/native/app_icon.rs @@ -0,0 +1,239 @@ +//! Set the native app icon at runtime. +//! +//! TODO(emilk): port this to [`winit`]. + +use crate::IconData; + +pub struct AppTitleIconSetter { + title: String, + icon_data: Option, + status: AppIconStatus, +} + +impl AppTitleIconSetter { + pub fn new(title: String, icon_data: Option) -> Self { + Self { + title, + icon_data, + status: AppIconStatus::NotSetTryAgain, + } + } + + /// Call once per frame; we will set the icon when we can. + pub fn update(&mut self) { + if self.status == AppIconStatus::NotSetTryAgain { + self.status = set_title_and_icon(&self.title, self.icon_data.as_ref()); + } + } +} + +/// In which state the app icon is (as far as we know). +#[derive(PartialEq, Eq)] +enum AppIconStatus { + /// We did not set it or failed to do it. In any case we won't try again. + NotSetIgnored, + + /// We haven't set the icon yet, we should try again next frame. + /// + /// This can happen repeatedly due to lazy window creation on some platforms. + NotSetTryAgain, + + /// We successfully set the icon and it should be visible now. + #[allow(dead_code)] // Not used on Linux + Set, +} + +/// Sets app icon at runtime. +/// +/// By setting the icon at runtime and not via resource files etc. we ensure that we'll get the chance +/// to set the same icon when the process/window is started from python (which sets its own icon ahead of us!). +/// +/// Since window creation can be lazy, call this every frame until it's either successfully or gave up. +/// (See [`AppIconStatus`]) +fn set_title_and_icon(_title: &str, _icon_data: Option<&IconData>) -> AppIconStatus { + crate::profile_function!(); + + #[cfg(target_os = "windows")] + { + if let Some(icon_data) = _icon_data { + return set_app_icon_windows(icon_data); + } + } + + #[cfg(target_os = "macos")] + return set_title_and_icon_mac(_title, _icon_data); + + #[allow(unreachable_code)] + AppIconStatus::NotSetIgnored +} + +/// Set icon for Windows applications. +#[cfg(target_os = "windows")] +#[allow(unsafe_code)] +fn set_app_icon_windows(icon_data: &IconData) -> AppIconStatus { + use winapi::um::winuser; + + // We would get fairly far already with winit's `set_window_icon` (which is exposed to eframe) actually! + // However, it only sets ICON_SMALL, i.e. doesn't allow us to set a higher resolution icon for the task bar. + // Also, there is scaling issues, detailed below. + + // TODO(andreas): This does not set the task bar icon for when our application is started from python. + // Things tried so far: + // * Querying for an owning window and setting icon there (there doesn't seem to be an owning window) + // * using undocumented SetConsoleIcon method (successfully queried via GetProcAddress) + + // SAFETY: WinApi function without side-effects. + let window_handle = unsafe { winuser::GetActiveWindow() }; + if window_handle.is_null() { + // The Window isn't available yet. Try again later! + return AppIconStatus::NotSetTryAgain; + } + + fn create_hicon_with_scale( + unscaled_image: &image::RgbaImage, + target_size: i32, + ) -> winapi::shared::windef::HICON { + let image_scaled = image::imageops::resize( + unscaled_image, + target_size as _, + target_size as _, + image::imageops::Lanczos3, + ); + + // Creating transparent icons with WinApi is a huge mess. + // We'd need to go through CreateIconIndirect's ICONINFO struct which then + // takes a mask HBITMAP and a color HBITMAP and creating each of these is pain. + // Instead we workaround this by creating a png which CreateIconFromResourceEx magically understands. + // This is a pretty horrible hack as we spend a lot of time encoding, but at least the code is a lot shorter. + let mut image_scaled_bytes: Vec = Vec::new(); + if image_scaled + .write_to( + &mut std::io::Cursor::new(&mut image_scaled_bytes), + image::ImageOutputFormat::Png, + ) + .is_err() + { + return std::ptr::null_mut(); + } + + // SAFETY: Creating an HICON which should be readonly on our data. + unsafe { + winuser::CreateIconFromResourceEx( + image_scaled_bytes.as_mut_ptr(), + image_scaled_bytes.len() as u32, + 1, // Means this is an icon, not a cursor. + 0x00030000, // Version number of the HICON + target_size, // Note that this method can scale, but it does so *very* poorly. So let's avoid that! + target_size, + winuser::LR_DEFAULTCOLOR, + ) + } + } + + let unscaled_image = match icon_data.to_image() { + Ok(unscaled_image) => unscaled_image, + Err(err) => { + log::warn!("Invalid icon: {err}"); + return AppIconStatus::NotSetIgnored; + } + }; + + // Only setting ICON_BIG with the icon size for big icons (SM_CXICON) works fine + // but the scaling it does then for the small icon is pretty bad. + // Instead we set the correct sizes manually and take over the scaling ourselves. + // For this to work we first need to set the big icon and then the small one. + // + // Note that ICON_SMALL may be used even if we don't render a title bar as it may be used in alt+tab! + { + // SAFETY: WinAPI getter function with no known side effects. + let icon_size_big = unsafe { winuser::GetSystemMetrics(winuser::SM_CXICON) }; + let icon_big = create_hicon_with_scale(&unscaled_image, icon_size_big); + if icon_big.is_null() { + log::warn!("Failed to create HICON (for big icon) from embedded png data."); + return AppIconStatus::NotSetIgnored; // We could try independently with the small icon but what's the point, it would look bad! + } else { + // SAFETY: Unsafe WinApi function, takes objects previously created with WinAPI, all checked for null prior. + unsafe { + winuser::SendMessageW( + window_handle, + winuser::WM_SETICON, + winuser::ICON_BIG as usize, + icon_big as isize, + ); + } + } + } + { + // SAFETY: WinAPI getter function with no known side effects. + let icon_size_small = unsafe { winuser::GetSystemMetrics(winuser::SM_CXSMICON) }; + let icon_small = create_hicon_with_scale(&unscaled_image, icon_size_small); + if icon_small.is_null() { + log::warn!("Failed to create HICON (for small icon) from embedded png data."); + return AppIconStatus::NotSetIgnored; + } else { + // SAFETY: Unsafe WinApi function, takes objects previously created with WinAPI, all checked for null prior. + unsafe { + winuser::SendMessageW( + window_handle, + winuser::WM_SETICON, + winuser::ICON_SMALL as usize, + icon_small as isize, + ); + } + } + } + + // It _probably_ worked out. + AppIconStatus::Set +} + +/// Set icon & app title for `MacOS` applications. +#[cfg(target_os = "macos")] +#[allow(unsafe_code)] +fn set_title_and_icon_mac(title: &str, icon_data: Option<&IconData>) -> AppIconStatus { + use cocoa::{ + appkit::{NSApp, NSApplication, NSImage, NSMenu, NSWindow}, + base::{id, nil}, + foundation::{NSData, NSString}, + }; + use objc::{msg_send, sel, sel_impl}; + + let png_bytes = if let Some(icon_data) = icon_data { + match icon_data.to_png_bytes() { + Ok(png_bytes) => Some(png_bytes), + Err(err) => { + log::warn!("Failed to convert IconData to png: {err}"); + return AppIconStatus::NotSetIgnored; + } + } + } else { + None + }; + + // SAFETY: Accessing raw data from icon in a read-only manner. Icon data is static! + unsafe { + let app = NSApp(); + + if let Some(png_bytes) = png_bytes { + let data = NSData::dataWithBytes_length_( + nil, + png_bytes.as_ptr().cast::(), + png_bytes.len() as u64, + ); + let app_icon = NSImage::initWithData_(NSImage::alloc(nil), data); + 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]; + app_menu.setTitle_(NSString::alloc(nil).init_str(title)); + + // The title in the Dock apparently can't be changed. + // At least these people didn't figure it out either: + // https://stackoverflow.com/questions/69831167/qt-change-application-title-dynamically-on-macos + // https://stackoverflow.com/questions/28808226/changing-cocoa-app-icon-title-and-menu-labels-at-runtime + } + + AppIconStatus::Set +} diff --git a/crates/eframe/src/native/epi_integration.rs b/crates/eframe/src/native/epi_integration.rs index f7e79a9b..cd2d83fe 100644 --- a/crates/eframe/src/native/epi_integration.rs +++ b/crates/eframe/src/native/epi_integration.rs @@ -329,6 +329,7 @@ pub struct EpiIntegration { can_drag_window: bool, window_state: WindowState, follow_system_theme: bool, + app_icon_setter: super::app_icon::AppTitleIconSetter, } impl EpiIntegration { @@ -338,7 +339,8 @@ impl EpiIntegration { max_texture_side: usize, window: &winit::window::Window, system_theme: Option, - follow_system_theme: bool, + app_name: &str, + native_options: &crate::NativeOptions, storage: Option>, #[cfg(feature = "glow")] gl: Option>, #[cfg(feature = "wgpu")] wgpu_render_state: Option, @@ -378,6 +380,11 @@ impl EpiIntegration { egui_winit.set_max_texture_side(max_texture_side); egui_winit.set_pixels_per_point(native_pixels_per_point); + let app_icon_setter = super::app_icon::AppTitleIconSetter::new( + app_name.to_owned(), + native_options.icon_data.clone(), + ); + Self { frame, last_auto_save: std::time::Instant::now(), @@ -387,7 +394,8 @@ impl EpiIntegration { close: false, can_drag_window: false, window_state, - follow_system_theme, + follow_system_theme: native_options.follow_system_theme, + app_icon_setter, } } @@ -474,6 +482,8 @@ impl EpiIntegration { ) -> egui::FullOutput { let frame_start = std::time::Instant::now(); + self.app_icon_setter.update(); + self.frame.info.window_info = read_window_info(window, self.egui_ctx.pixels_per_point(), &self.window_state); let raw_input = self.egui_winit.take_egui_input(window); diff --git a/crates/eframe/src/native/mod.rs b/crates/eframe/src/native/mod.rs index 3718d60c..8b606155 100644 --- a/crates/eframe/src/native/mod.rs +++ b/crates/eframe/src/native/mod.rs @@ -1,3 +1,4 @@ +mod app_icon; mod epi_integration; pub mod run; diff --git a/crates/eframe/src/native/run.rs b/crates/eframe/src/native/run.rs index 1d3e461d..15a89e3e 100644 --- a/crates/eframe/src/native/run.rs +++ b/crates/eframe/src/native/run.rs @@ -702,7 +702,8 @@ mod glow_integration { painter.max_texture_side(), gl_window.window(), system_theme, - self.native_options.follow_system_theme, + &self.app_name, + &self.native_options, storage, Some(gl.clone()), #[cfg(feature = "wgpu")] @@ -1166,7 +1167,8 @@ mod wgpu_integration { painter.max_texture_side().unwrap_or(2048), &window, system_theme, - self.native_options.follow_system_theme, + &self.app_name, + &self.native_options, storage, #[cfg(feature = "glow")] None, diff --git a/crates/egui_demo_app/src/main.rs b/crates/egui_demo_app/src/main.rs index 177e24a7..9ab02770 100644 --- a/crates/egui_demo_app/src/main.rs +++ b/crates/egui_demo_app/src/main.rs @@ -22,6 +22,11 @@ fn main() -> Result<(), eframe::Error> { initial_window_size: Some([1280.0, 1024.0].into()), + icon_data: Some( + eframe::IconData::try_from_png_bytes(&include_bytes!("../../../media/icon.png")[..]) + .unwrap(), + ), + #[cfg(feature = "wgpu")] renderer: eframe::Renderer::Wgpu, diff --git a/media/icon.png b/media/icon.png new file mode 100644 index 00000000..ee933460 Binary files /dev/null and b/media/icon.png differ