From bb990ab9ac8375ba7a7903e92d4bbcedd3d0e7cf 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 988834fa50..aaa953d50f 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 efca26a6b4..5028915c96 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 dc357bda80..86e48def36 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 9078fa82c2..a088a5205f 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 f29ba49fb2..c21d8baf31 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 8811a4159a..7ce5badb2b 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 915748f50b..1632f9f925 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 4b27d939ae..80bf1d8642 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 fd28c268e0..bb4a8dc295 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),