From fe7addb13e448968ca4e1b8b06946151a01bef04 Mon Sep 17 00:00:00 2001 From: kittcat Date: Tue, 6 Jan 2026 11:39:54 -0800 Subject: [PATCH] Set window icon on X11 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 --- 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(-) diff --git a/Cargo.lock b/Cargo.lock index 85d3f9ac3a68aa8a420910dfa61102f03c9812ef..d5b3281dbb62e6bfce8e7914d469e8815a7863e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7592,6 +7592,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 5778d6ac7372f4b13f14d4fa7d0ebca54a03fd1d..44990c8c9ddb423aaa030f5161046dd07006d1d5 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,8 @@ impl Window { show, display_id, window_min_size, + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + 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 1974cc0bb28f62da4d7dcb3e9fca92b6324470bb..0607283f04e71de312bf8212879f87daf453f7f4 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, @@ -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 = 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/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..c741a974d7ab10e07b8245540ce71521ef114f09 100644 --- a/crates/zed/build.rs +++ b/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 { 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()); } diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 3d4ada8a1b90020090eb74a8a6ea752fa7a44ab3..66aa2e5cc773c4d5a67798b4a7ed254f44c5a50c 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -325,6 +325,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, @@ -339,6 +354,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),