feat: support app-defined icon sets

This commit is contained in:
2026-11-09 20:35:04 +07:00
parent 1c091db699
commit ec906839ed
17 changed files with 274 additions and 173 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
name = "gpui-native-icons"
version = "0.1.0"
edition = "2024"
description = "System icons for GPUI: semantic roles resolved per platform, with a bundled Lucide fallback"
description = "System icons for GPUI: semantic roles resolved per platform, a bundled Lucide fallback, and app-defined icon sets"
[dependencies]
anyhow = "1"
+124 -58
View File
@@ -1,10 +1,11 @@
//! System icons for GPUI: components and apps reference semantic roles,
//! and the crate resolves each role to a glyph for the current platform.
//! Apps ship their own icon set by implementing [`IconNamed`].
use gpui::prelude::FluentBuilder as _;
use gpui::{
AssetSource, Hsla, IntoElement, ParentElement as _, Pixels, RenderOnce, SharedString,
Styled as _, Transformation, Window, div, px, svg,
AnyElement, AssetSource, Hsla, IntoElement, ParentElement as _, Pixels, RenderOnce,
SharedString, Styled as _, Transformation, Window, div, px, svg,
};
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
@@ -194,10 +195,29 @@ impl IconRole {
}
}
/// A glyph named by an application's own icon set.
pub trait IconNamed {
/// The SVG asset path, e.g. `icons/commit.svg`.
fn path(self) -> SharedString;
}
impl IconNamed for IconRole {
fn path(self) -> SharedString {
SharedString::from(self.asset_path())
}
}
/// The glyph an icon draws.
#[derive(Clone)]
enum Glyph {
Role(IconRole),
Named(SharedString),
}
/// An icon element. Unpainted unless a color is set.
#[derive(IntoElement)]
#[derive(Clone, IntoElement)]
pub struct Icon {
role: IconRole,
glyph: Glyph,
size: Pixels,
color: Option<Hsla>,
transformation: Option<Transformation>,
@@ -206,7 +226,7 @@ pub struct Icon {
/// Creates an icon for the role at the default size of 16px.
pub fn icon(role: IconRole) -> Icon {
Icon {
role,
glyph: Glyph::Role(role),
size: px(16.0),
color: None,
transformation: None,
@@ -214,6 +234,19 @@ pub fn icon(role: IconRole) -> Icon {
}
impl Icon {
/// Creates an icon from an application-defined icon set.
pub fn named<I>(named: I) -> Self
where
I: IconNamed,
{
Self {
glyph: Glyph::Named(named.path()),
size: px(16.0),
color: None,
transformation: None,
}
}
pub fn size(mut self, size: Pixels) -> Self {
self.size = size;
self
@@ -232,66 +265,99 @@ impl Icon {
/// The asset key a resolved freedesktop file is served under.
#[cfg(target_os = "linux")]
fn freedesktop_asset_path(&self) -> Option<SharedString> {
let size = self.size.as_f32().max(1.0).round() as u32;
freedesktop::cached_file(self.role, size).map(|_| {
SharedString::from(format!("icons/freedesktop/{}-{size}.svg", self.role.slug()))
})
fn freedesktop_asset_path(size: Pixels, role: &IconRole) -> Option<SharedString> {
let size = size.as_f32().max(1.0).round() as u32;
freedesktop::cached_file(*role, size)
.map(|_| SharedString::from(format!("icons/freedesktop/{}-{size}.svg", role.slug())))
}
/// Roles resolve per platform.
fn render_role(
size: Pixels,
color: Option<Hsla>,
transformation: Option<Transformation>,
role: IconRole,
) -> AnyElement {
#[cfg(target_os = "windows")]
if let Some(glyph) = segoe::glyph(role) {
return Self::render_glyph(size, color, glyph).into_any_element();
}
#[cfg(target_os = "linux")]
if let Some(path) = Self::freedesktop_asset_path(size, &role) {
return Self::render_svg_path(size, color, transformation, path).into_any_element();
}
Self::render_svg_path(
size,
color,
transformation,
SharedString::from(role.asset_path()),
)
.into_any_element()
}
/// Windows: the Segoe Fluent codepoint drawn as text in the system font.
#[cfg(target_os = "windows")]
fn render_glyph(size: Pixels, color: Option<Hsla>, glyph: char) -> AnyElement {
div()
.flex_none()
.size(size)
.flex()
.items_center()
.justify_center()
.child(
div()
.font_family(segoe::FONT_FAMILY)
.text_size(size)
.when_some(color, |el, color| el.text_color(color))
.child(glyph.to_string()),
)
.into_any_element()
}
fn render_svg_path(
size: Pixels,
color: Option<Hsla>,
transformation: Option<Transformation>,
path: SharedString,
) -> AnyElement {
div()
.flex_none()
.size(size)
.child(
svg()
.size_full()
.path(path)
.when_some(color, |el, color| el.text_color(color))
.when_some(transformation, |el, transformation| {
el.with_transformation(transformation)
}),
)
.into_any_element()
}
}
impl<I> From<I> for Icon
where
I: IconNamed,
{
fn from(named: I) -> Self {
Icon::named(named)
}
}
impl RenderOnce for Icon {
fn render(self, _window: &mut Window, _cx: &mut gpui::App) -> impl IntoElement {
#[cfg(target_os = "windows")]
return self.render_glyph();
#[cfg(not(target_os = "windows"))]
return self.render_svg();
}
}
impl Icon {
/// Windows: the Segoe Fluent codepoint drawn as text in the system font;
/// roles without a codepoint fall back to the bundled SVG.
#[cfg(target_os = "windows")]
fn render_glyph(self) -> impl IntoElement {
match segoe::glyph(self.role) {
Some(glyph) => div()
.flex_none()
.size(self.size)
.flex()
.items_center()
.justify_center()
.child(
div()
.font_family(segoe::FONT_FAMILY)
.text_size(self.size)
.when_some(self.color, |el, color| el.text_color(color))
.child(glyph.to_string()),
)
.into_any_element(),
None => self.render_svg().into_any_element(),
let Icon {
glyph,
size,
color,
transformation,
} = self;
match glyph {
Glyph::Named(path) => Self::render_svg_path(size, color, transformation, path),
Glyph::Role(role) => Self::render_role(size, color, transformation, role),
}
}
fn render_svg(self) -> impl IntoElement {
#[cfg(target_os = "linux")]
let path: SharedString = self
.freedesktop_asset_path()
.unwrap_or_else(|| SharedString::from(self.role.asset_path()));
#[cfg(not(target_os = "linux"))]
let path: SharedString = SharedString::from(self.role.asset_path());
div().flex_none().size(self.size).child(
svg()
.size_full()
.path(path)
.when_some(self.color, |el, color| el.text_color(color))
.when_some(self.transformation, |el, transformation| {
el.with_transformation(transformation)
}),
)
}
}
/// The asset source serving this crate's bundled glyphs.