Set window icon on X11

kittcat created

This only decodes the icon to RgbaImage once, instead of per window,
adds FreeBSD support for X11 window icon and moves icon processing to
separate function in build.rs

Change summary

Cargo.lock                                |  1 
crates/gpui/src/platform.rs               |  8 +++
crates/gpui/src/window.rs                 |  7 ++
crates/gpui_linux/Cargo.toml              |  1 
crates/gpui_linux/src/linux/x11/window.rs | 24 +++++++++
crates/zed/Cargo.toml                     |  7 ++
crates/zed/build.rs                       | 64 ++++++++++++++++++++----
crates/zed/src/zed.rs                     | 17 ++++++
8 files changed, 117 insertions(+), 12 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -7592,6 +7592,7 @@ dependencies = [
  "gpui",
  "gpui_wgpu",
  "http_client",
+ "image",
  "itertools 0.14.0",
  "libc",
  "log",

crates/gpui/src/platform.rs 🔗

@@ -1424,6 +1424,9 @@ pub struct WindowOptions {
     /// Note that this may be ignored.
     pub window_decorations: Option<WindowDecorations>,
 
+    /// Icon image (X11 only)
+    pub icon: Option<Arc<image::RgbaImage>>,
+
     /// 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<String>,
 }
@@ -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<Arc<image::RgbaImage>>,
+
     #[cfg_attr(feature = "wayland", allow(dead_code))]
     pub display_id: Option<DisplayId>,
 
@@ -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,

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,8 @@ impl Window {
                 show,
                 display_id,
                 window_min_size,
+                #[cfg(any(target_os = "linux", target_os = "freebsd"))]
+                icon,
                 #[cfg(target_os = "macos")]
                 tabbing_identifier,
             },

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"] }

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,
@@ -740,6 +741,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<u32> = 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 {

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"

crates/zed/build.rs 🔗

@@ -1,4 +1,5 @@
 #![allow(clippy::disallowed_methods, reason = "build scripts are exempt")]
+use std::path::Path;
 use std::process::Command;
 
 fn main() {
@@ -202,18 +203,8 @@ fn main() {
             }
         }
 
-        let release_channel = option_env!("RELEASE_CHANNEL").unwrap_or("dev");
-        let icon = match release_channel {
-            "stable" => "resources/windows/app-icon.ico",
-            "preview" => "resources/windows/app-icon-preview.ico",
-            "nightly" => "resources/windows/app-icon-nightly.ico",
-            "dev" => "resources/windows/app-icon-dev.ico",
-            _ => "resources/windows/app-icon-dev.ico",
-        };
-        let icon = std::path::Path::new(icon);
-
         println!("cargo:rerun-if-env-changed=RELEASE_CHANNEL");
-        println!("cargo:rerun-if-changed={}", icon.display());
+        println!("cargo:rerun-if-changed={}", icon_path().display());
 
         #[cfg(windows)]
         {
@@ -225,7 +216,7 @@ fn main() {
             if let Some(explicit_rc_toolkit_path) = std::env::var("ZED_RC_TOOLKIT_PATH").ok() {
                 res.set_toolkit_path(explicit_rc_toolkit_path.as_str());
             }
-            res.set_icon(icon.to_str().unwrap());
+            res.set_icon(icon_path().to_str().unwrap());
             res.set("FileDescription", "Zed");
             res.set("ProductName", "Zed");
 
@@ -235,4 +226,53 @@ fn main() {
             }
         }
     }
+
+    #[cfg(any(target_os = "linux", target_os = "freebsd"))]
+    prepare_app_icon_x11();
+}
+
+fn icon_path() -> &'static Path {
+    let release_channel = option_env!("RELEASE_CHANNEL").unwrap_or("dev");
+    let icon = match release_channel {
+        "stable" => "resources/app-icon.png",
+        "preview" => "resources/app-icon-preview.png",
+        "nightly" => "resources/app-icon-nightly.png",
+        "dev" | _ => "resources/app-icon-dev.png",
+    };
+
+    Path::new(icon)
+}
+
+fn prepare_app_icon_x11() {
+    use image::{DynamicImage, ImageReader, ImageResult, imageops};
+    use std::env;
+    use std::path::Path;
+
+    let out_dir = env::var("OUT_DIR").unwrap();
+
+    let resized_image =
+        match || -> ImageResult<DynamicImage> { Ok(ImageReader::open(icon_path())?.decode()?) }() {
+            Err(msg) => {
+                eprintln!("failed to read or decode {}: {msg}", icon_path().display());
+                std::process::exit(1);
+            }
+            Ok(image) => imageops::resize(&image, 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");
+
+    // verify icon can be read and decoded
+    if let Err(msg) = ImageReader::open(&icon_out_path).unwrap().decode() {
+        eprintln!(
+            "error verifying {}: {msg} (resized from {})",
+            icon_out_path.display(),
+            icon_path().display(),
+        );
+        std::process::exit(1);
+    }
+
+    println!("cargo:rerun-if-env-changed=RELEASE_CHANNEL");
+    println!("cargo:rerun-if-changed={}", icon_path().to_string_lossy());
 }

crates/zed/src/zed.rs 🔗

@@ -325,6 +325,21 @@ pub fn build_window_options(display_uuid: Option<Uuid>, 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<Option<std::sync::Arc<image::RgbaImage>>> =
+        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,
@@ -339,6 +354,8 @@ pub fn build_window_options(display_uuid: Option<Uuid>, 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),