From 24a304c1406c05e353933488f83cd787a3b97d8f Mon Sep 17 00:00:00 2001 From: kitt <11167504+kitt-cat@users.noreply.github.com> Date: Tue, 14 Apr 2026 07:21:28 -0700 Subject: [PATCH] Set window icon on X11 (#40096) Closes #30644 Many X11 environments expect a window icon to be supplied [as pixel data on a window property `_NET_WM_ICON`](https://specifications.freedesktop.org/wm-spec/1.4/ar01s05.html#id-1.6.13). I confirmed this change fixes the icon in xfce4 for me, I think its likely it also fixes https://github.com/zed-industries/zed/issues/37961 but I haven't tested it. ## Questions * [`image::RgbaImage` is exposed to the public API of gpui](https://github.com/zed-industries/zed/pull/40096/files#diff-318f166d72ad9476bd0a116446f5db3897fc1a4eb1d49aaf8105608bcf49ea53R1136). I would guess this is undesirable, but I wasn't sure of the best way to use gpui's native `Image` type.. * Currently [the icon is embedded into the binary](https://github.com/zed-industries/zed/pull/40096/files#diff-89af0b4072205c53b518aa977d6be48997e1a51fa4dbf06c7ddd1fec99fc510eR101). If this is undesirable, zed could alternatively implement [icon lookup](https://specifications.freedesktop.org/icon-theme-spec/latest/#icon_lookup) and try and find its icon from the system at runtime. ## Future work * It might be nice to expose a `set_window_icon` method also (it could be used for example to show dirty state in the icon somehow), but I'm unfamiliar with what other platforms support and if this could be beyond X11 (there is a [wayland protocol](https://wayland.app/protocols/xdg-toplevel-icon-v1) though!). Release Notes: - Fixed missing window icon on X11 --------- Co-authored-by: Yara --- Cargo.lock | 1 + crates/gpui/src/platform.rs | 8 ++++ crates/gpui/src/window.rs | 6 +++ crates/gpui_linux/Cargo.toml | 1 + crates/gpui_linux/src/linux/x11/window.rs | 24 ++++++++++++ crates/gpui_macos/src/window.rs | 1 + crates/zed/Cargo.toml | 7 ++++ crates/zed/build.rs | 46 +++++++++++++++++++++++ crates/zed/src/zed.rs | 17 +++++++++ 9 files changed, 111 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 988834fa50cb4b402acb26104e964cce848d1e23..aaa953d50fe3962d3a502342890caa1bae42c25f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7584,6 +7584,7 @@ dependencies = [ "gpui", "gpui_wgpu", "http_client", + "image", "itertools 0.14.0", "libc", "log", diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index efca26a6b4802037a96490bf81f7d1c5c1d8b298..5028915c968d07994c7496728d7a71ad593fe502 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -1424,6 +1424,9 @@ pub struct WindowOptions { /// Note that this may be ignored. pub window_decorations: Option, + /// Icon image (X11 only) + pub icon: Option>, + /// Tab group name, allows opening the window as a native tab on macOS 10.12+. Windows with the same tabbing identifier will be grouped together. pub tabbing_identifier: Option, } @@ -1470,6 +1473,10 @@ pub struct WindowParams { #[cfg_attr(any(target_os = "linux", target_os = "freebsd"), allow(dead_code))] pub show: bool, + /// An image to set as the window icon (x11 only) + #[cfg_attr(feature = "wayland", allow(dead_code))] + pub icon: Option>, + #[cfg_attr(feature = "wayland", allow(dead_code))] pub display_id: Option, @@ -1530,6 +1537,7 @@ impl Default for WindowOptions { is_minimizable: true, display_id: None, window_background: WindowBackgroundAppearance::default(), + icon: None, app_id: None, window_min_size: None, window_decorations: None, diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index dc357bda80f4329a1ae5b9894ea329c44e483475..86e48def36e73f592d5e9c019c4d89d2fd8fae42 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -1133,6 +1133,11 @@ impl Window { app_id, window_min_size, window_decorations, + #[cfg_attr( + not(any(target_os = "linux", target_os = "freebsd")), + allow(unused_variables) + )] + icon, #[cfg_attr(not(target_os = "macos"), allow(unused_variables))] tabbing_identifier, } = options; @@ -1151,6 +1156,7 @@ impl Window { show, display_id, window_min_size, + icon, #[cfg(target_os = "macos")] tabbing_identifier, }, diff --git a/crates/gpui_linux/Cargo.toml b/crates/gpui_linux/Cargo.toml index 9078fa82c2884421c6cd11c6d3384645621b7e6f..a088a5205fd8c23a251c2f93cc563de144991e44 100644 --- a/crates/gpui_linux/Cargo.toml +++ b/crates/gpui_linux/Cargo.toml @@ -54,6 +54,7 @@ screen-capture = [ anyhow.workspace = true bytemuck = "1" collections.workspace = true +image.workspace = true futures.workspace = true gpui.workspace = true gpui_wgpu = { workspace = true, optional = true, features = ["font-kit"] } diff --git a/crates/gpui_linux/src/linux/x11/window.rs b/crates/gpui_linux/src/linux/x11/window.rs index f29ba49fb2498dd49a5f025aad4dc2584a8a8a42..c21d8baf31de06e6fe155678c5243c21ec6cba53 100644 --- a/crates/gpui_linux/src/linux/x11/window.rs +++ b/crates/gpui_linux/src/linux/x11/window.rs @@ -60,6 +60,7 @@ x11rb::atom_manager! { WM_TRANSIENT_FOR, _NET_WM_PID, _NET_WM_NAME, + _NET_WM_ICON, _NET_WM_STATE, _NET_WM_STATE_MAXIMIZED_VERT, _NET_WM_STATE_MAXIMIZED_HORZ, @@ -743,6 +744,29 @@ impl X11WindowState { size_hints.set_normal_hints(xcb, x_window), )?; + if let Some(image) = params.icon { + // https://specifications.freedesktop.org/wm-spec/1.4/ar01s05.html#id-1.6.13 + let property_size = 2 + (image.width() * image.height()) as usize; + let mut property_data: Vec = Vec::with_capacity(property_size); + property_data.push(image.width()); + property_data.push(image.height()); + property_data.extend(image.pixels().map(|px| { + let [r, g, b, a]: [u8; 4] = px.0; + u32::from_le_bytes([b, g, r, a]) + })); + + check_reply( + || "X11 ChangeProperty32 for _NET_ICON_NAME failed.", + xcb.change_property32( + xproto::PropMode::REPLACE, + x_window, + atoms._NET_WM_ICON, + xproto::AtomEnum::CARDINAL, + &property_data, + ), + )?; + } + let display = Rc::new(X11Display::new(xcb, scale_factor, x_screen_index)?); Ok(Self { diff --git a/crates/gpui_macos/src/window.rs b/crates/gpui_macos/src/window.rs index 8811a4159a0f539d2bae2c62242a3d5f490686ef..7ce5badb2baa967b613d7298cb4bcbc6584fe3cc 100644 --- a/crates/gpui_macos/src/window.rs +++ b/crates/gpui_macos/src/window.rs @@ -628,6 +628,7 @@ impl MacWindow { display_id, window_min_size, tabbing_identifier, + .. }: WindowParams, foreground_executor: ForegroundExecutor, background_executor: BackgroundExecutor, diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 915748f50bf75c63ff8f658581d2f247e2d2e44b..1632f9f925c38bd7740aabcab27eeb901607b14b 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -240,6 +240,11 @@ gpui = { workspace = true, features = [ "x11", ] } ashpd.workspace = true +image.workspace = true + +[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.build-dependencies] +image.workspace = true + [target.'cfg(target_os = "linux")'.build-dependencies] pkg-config = "0.3.22" @@ -262,6 +267,8 @@ agent_ui = { workspace = true, features = ["test-support"] } search = { workspace = true, features = ["test-support"] } repl = { workspace = true, features = ["test-support"] } + + [package.metadata.bundle-dev] icon = ["resources/app-icon-dev@2x.png", "resources/app-icon-dev.png"] identifier = "dev.zed.Zed-Dev" diff --git a/crates/zed/build.rs b/crates/zed/build.rs index 4b27d939aee833058d03771907b53324a6ce50d0..80bf1d8642e253cb2e0dfb1389a33af5cb1b516b 100644 --- a/crates/zed/build.rs +++ b/crates/zed/build.rs @@ -235,4 +235,50 @@ fn main() { } } } + + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + prepare_app_icon_x11(); +} + +#[cfg(any(target_os = "linux", target_os = "freebsd"))] +fn icon_path() -> std::path::PathBuf { + use std::str::FromStr; + + let release_channel = option_env!("RELEASE_CHANNEL").unwrap_or("dev"); + let channel = match release_channel { + "stable" => "", + "preview" => "-preview", + "nightly" => "-nightly", + "dev" => "-dev", + _ => "-dev", + }; + + #[cfg(windows)] + let icon = format!("resources/windows/app-icon{}.ico", channel); + #[cfg(not(windows))] + let icon = format!("resources/app-icon{}.png", channel); + + std::path::PathBuf::from_str(&icon).unwrap() +} + +#[cfg(any(target_os = "linux", target_os = "freebsd"))] +fn prepare_app_icon_x11() { + use image::{ImageReader, imageops}; + use std::env; + use std::path::Path; + + let out_dir = env::var("OUT_DIR").unwrap(); + + let resized_image = ImageReader::open(icon_path()) + .unwrap() + .decode() + .unwrap() + .resize(256, 256, imageops::FilterType::Lanczos3); + + // name should match include_bytes! call in src/zed.rs + let icon_out_path = Path::new(&out_dir).join("app_icon.png"); + resized_image.save(&icon_out_path).expect("saving app icon"); + + println!("cargo:rerun-if-env-changed=RELEASE_CHANNEL"); + println!("cargo:rerun-if-changed={}", icon_path().to_string_lossy()); } diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index fd28c268e0a753cb41661aab5a9ac457b2518f89..bb4a8dc2950dd397fd71576c7da4d078bc1d43c7 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -324,6 +324,21 @@ pub fn build_window_options(display_uuid: Option, cx: &mut App) -> WindowO let use_system_window_tabs = WorkspaceSettings::get_global(cx).use_system_window_tabs; + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + static APP_ICON: std::sync::LazyLock>> = + std::sync::LazyLock::new(|| { + // this shouldn't fail since decode is checked in build.rs + const BYTES: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/app_icon.png")); + util::maybe!({ + let image = image::ImageReader::new(std::io::Cursor::new(BYTES)) + .with_guessed_format()? + .decode()? + .into(); + anyhow::Ok(Arc::new(image)) + }) + .log_err() + }); + WindowOptions { titlebar: Some(TitlebarOptions { title: None, @@ -338,6 +353,8 @@ pub fn build_window_options(display_uuid: Option, cx: &mut App) -> WindowO display_id: display.map(|display| display.id()), window_background: cx.theme().window_background_appearance(), app_id: Some(app_id.to_owned()), + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + icon: APP_ICON.as_ref().cloned(), window_decorations: Some(window_decorations), window_min_size: Some(gpui::Size { width: px(360.0),