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 <git@yara.blue>
This commit is contained in:
kitt 2026-04-14 07:21:28 -07:00 committed by GitHub
parent 5c7325ad9a
commit 24a304c140
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 111 additions and 0 deletions

1
Cargo.lock generated
View file

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

View file

@ -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,

View file

@ -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,
},

View file

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

View file

@ -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<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 {

View file

@ -628,6 +628,7 @@ impl MacWindow {
display_id,
window_min_size,
tabbing_identifier,
..
}: WindowParams,
foreground_executor: ForegroundExecutor,
background_executor: BackgroundExecutor,

View file

@ -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"

View file

@ -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());
}

View file

@ -324,6 +324,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,
@ -338,6 +353,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),