From ec906839ed2aa62b2c39df3d35965028d9410b67 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Mon, 9 Nov 2026 20:35:04 +0700 Subject: [PATCH] feat: support app-defined icon sets --- crates/core/src/alert.rs | 25 ++-- crates/core/src/button.rs | 15 ++- crates/core/src/command_palette.rs | 15 ++- crates/core/src/dropdown_menu.rs | 15 ++- crates/core/src/empty_state.rs | 15 ++- crates/core/src/menu.rs | 17 +-- crates/core/src/segmented.rs | 15 ++- crates/core/src/select.rs | 15 ++- crates/core/src/toast.rs | 15 ++- crates/data/src/list.rs | 21 ++-- crates/data/src/sidebar.rs | 23 ++-- crates/data/src/tabs.rs | 27 +++-- crates/data/src/tree.rs | 19 ++- crates/icons/Cargo.toml | 2 +- crates/icons/src/lib.rs | 182 ++++++++++++++++++++--------- crates/layout/src/dock.rs | 23 ++-- docs/CHECKLIST.md | 3 + 17 files changed, 274 insertions(+), 173 deletions(-) diff --git a/crates/core/src/alert.rs b/crates/core/src/alert.rs index 8af6b5a..007cd47 100644 --- a/crates/core/src/alert.rs +++ b/crates/core/src/alert.rs @@ -6,7 +6,7 @@ use gpui::{ IntoElement, ParentElement as _, RenderOnce, SharedString, StatefulInteractiveElement as _, Styled as _, Window, deferred, div, ease_out_quint, px, }; -use gpui_native_icons::{IconRole, icon}; +use gpui_native_icons::{Icon, IconRole, icon}; use gpui_native_theme::{ActiveTheme as _, Size}; use crate::common::DismissHandler; @@ -33,7 +33,7 @@ pub struct Alert { title: Option, message: Option, severity: AlertSeverity, - icon: Option, + icon: Option, show_icon: bool, on_dismiss: Option, } @@ -78,9 +78,12 @@ impl Alert { self } - /// Overrides the severity-derived icon with this glyph. - pub fn icon(mut self, role: IconRole) -> Self { - self.icon = Some(role); + /// Overrides the severity-derived icon. + pub fn icon(mut self, icon: I) -> Self + where + I: Into, + { + self.icon = Some(icon.into()); self } @@ -116,10 +119,12 @@ impl RenderOnce for Alert { AlertSeverity::Danger => theme.ramps.destructive, }; - let icon_role = self.icon.unwrap_or(match self.severity { - AlertSeverity::Info => IconRole::Info, - AlertSeverity::Success => IconRole::Check, - AlertSeverity::Warning | AlertSeverity::Danger => IconRole::Warning, + let icon_glyph = self.icon.unwrap_or_else(|| { + icon(match self.severity { + AlertSeverity::Info => IconRole::Info, + AlertSeverity::Success => IconRole::Check, + AlertSeverity::Warning | AlertSeverity::Danger => IconRole::Warning, + }) }); let foreground = theme.colors.foreground; @@ -158,7 +163,7 @@ impl RenderOnce for Alert { .flex() .items_center() .h(window.line_height()) - .child(icon(icon_role).size(icon_size).color(ramp.base)), + .child(icon_glyph.size(icon_size).color(ramp.base)), ) }) .child( diff --git a/crates/core/src/button.rs b/crates/core/src/button.rs index 315e2a2..fc2a4da 100644 --- a/crates/core/src/button.rs +++ b/crates/core/src/button.rs @@ -6,7 +6,7 @@ use gpui::{ RenderOnce, Role, SharedString, StatefulInteractiveElement as _, Styled as _, Window, canvas, relative, transparent_black, }; -use gpui_native_icons::{IconRole, icon}; +use gpui_native_icons::Icon; use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, Size}; use gpui_native_utils::{focus_ring, record_anchor_bounds}; use std::rc::Rc; @@ -38,7 +38,7 @@ pub enum ButtonVariant { pub struct Button { id: ElementId, label: Option, - icon: Option, + icon: Option, variant: ButtonVariant, size: Size, disabled: bool, @@ -82,8 +82,11 @@ impl Button { } /// Shows an icon beside the label. - pub fn icon(mut self, role: IconRole) -> Self { - self.icon = Some(role); + pub fn icon(mut self, icon: I) -> Self + where + I: Into, + { + self.icon = Some(icon.into()); self } @@ -236,8 +239,8 @@ impl RenderOnce for Button { ) }) .when(!self.loading, |button| { - button.when_some(self.icon, |button, role| { - button.child(icon(role).size(metrics.icon_size).color(foreground)) + button.when_some(self.icon, |button, icon| { + button.child(icon.size(metrics.icon_size).color(foreground)) }) }) .when_some(self.label, |button, label| button.child(label)) diff --git a/crates/core/src/command_palette.rs b/crates/core/src/command_palette.rs index 213845a..97f8f7b 100644 --- a/crates/core/src/command_palette.rs +++ b/crates/core/src/command_palette.rs @@ -9,7 +9,7 @@ use gpui::{ StatefulInteractiveElement as _, Styled as _, Subscription, Window, actions, canvas, deferred, div, relative, }; -use gpui_native_icons::{IconRole, icon}; +use gpui_native_icons::Icon; use gpui_native_theme::{ActiveTheme as _, POPUP_MAX_HEIGHT, Size}; use crate::common::{h_flex, v_flex}; @@ -49,7 +49,7 @@ pub struct Command { /// Gray text on the row's right edge, e.g. the keybinding. pub hint: Option, /// Leading glyph shown before the label. - pub icon: Option, + pub icon: Option, } impl Command { @@ -64,8 +64,11 @@ impl Command { } /// Shows an icon before the label. - pub fn icon(mut self, role: IconRole) -> Self { - self.icon = Some(role); + pub fn icon(mut self, icon: I) -> Self + where + I: Into, + { + self.icon = Some(icon.into()); self } @@ -361,8 +364,8 @@ impl Render for CommandPaletteState { state.update(cx, |state, cx| state.choose_row(row_ix, window, cx)); } }) - .when_some(command.icon, |row, role| { - row.child(icon(role).size(metrics.icon_size).color(colors.foreground)) + .when_some(command.icon.clone(), |row, icon| { + row.child(icon.size(metrics.icon_size).color(colors.foreground)) }) .child( div() diff --git a/crates/core/src/dropdown_menu.rs b/crates/core/src/dropdown_menu.rs index 1f6a976..9560543 100644 --- a/crates/core/src/dropdown_menu.rs +++ b/crates/core/src/dropdown_menu.rs @@ -9,7 +9,7 @@ use gpui::{ MouseButton, MouseDownEvent, ParentElement as _, RenderOnce, Role, SharedString, StatefulInteractiveElement as _, Styled as _, Window, canvas, div, relative, }; -use gpui_native_icons::{IconRole, icon}; +use gpui_native_icons::{Icon, IconRole, icon}; use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, Size}; use gpui_native_utils::{focus_handle, focus_ring}; @@ -27,7 +27,7 @@ type ItemsFn = Box Vec>; pub struct DropdownMenu { id: ElementId, label: Option, - icon: Option, + icon: Option, variant: ButtonVariant, size: Size, disabled: bool, @@ -73,8 +73,11 @@ impl DropdownMenu { } /// Shows an icon beside the label. - pub fn icon(mut self, role: IconRole) -> Self { - self.icon = Some(role); + pub fn icon(mut self, icon: I) -> Self + where + I: Into, + { + self.icon = Some(icon.into()); self } @@ -267,8 +270,8 @@ impl RenderOnce for DropdownMenu { ) }) .when(!self.loading, |zone| { - zone.when_some(self.icon, |zone, role| { - zone.child(icon(role).size(metrics.icon_size).color(foreground)) + zone.when_some(self.icon, |zone, icon| { + zone.child(icon.size(metrics.icon_size).color(foreground)) }) }) .when_some(self.label, |zone, label| zone.child(label)); diff --git a/crates/core/src/empty_state.rs b/crates/core/src/empty_state.rs index a1e3ad3..7539ef4 100644 --- a/crates/core/src/empty_state.rs +++ b/crates/core/src/empty_state.rs @@ -3,7 +3,7 @@ use gpui::{ App, IntoElement, ParentElement as _, RenderOnce, SharedString, StyleRefinement, Styled as _, Window, div, relative, }; -use gpui_native_icons::{IconRole, icon}; +use gpui_native_icons::Icon; use gpui_native_theme::{ActiveTheme as _, Size}; use crate::common::{StyledExt, v_flex}; @@ -11,7 +11,7 @@ use crate::common::{StyledExt, v_flex}; /// The centered "nothing here yet" panel for lists, tables, and searches. #[derive(IntoElement)] pub struct EmptyState { - icon: Option, + icon: Option, headline: SharedString, hint: Option, action: Option, @@ -34,8 +34,11 @@ impl EmptyState { } /// Sets a large muted icon above the headline. - pub fn icon(mut self, role: IconRole) -> Self { - self.icon = Some(role); + pub fn icon(mut self, icon: I) -> Self + where + I: Into, + { + self.icon = Some(icon.into()); self } @@ -78,11 +81,11 @@ impl RenderOnce for EmptyState { .justify_center() .text_center() .p_4() - .when_some(self.icon, |state, role| { + .when_some(self.icon, |state, icon| { state.child( div() .mb_2() - .child(icon(role).size(illustration).color(muted_foreground)), + .child(icon.size(illustration).color(muted_foreground)), ) }) .child( diff --git a/crates/core/src/menu.rs b/crates/core/src/menu.rs index d9751b9..6061ed3 100644 --- a/crates/core/src/menu.rs +++ b/crates/core/src/menu.rs @@ -9,7 +9,7 @@ use gpui::{ StatefulInteractiveElement as _, Styled as _, Window, anchored, canvas, deferred, div, ease_out_quint, point, px, relative, }; -use gpui_native_icons::{IconRole, icon as icon_glyph}; +use gpui_native_icons::{Icon, IconRole, icon as icon_glyph}; use gpui_native_theme::{ActiveTheme as _, POPUP_MAX_HEIGHT}; use crate::common::v_flex; @@ -30,7 +30,7 @@ type ItemsFn = Box Vec>; pub struct MenuItem { kind: MenuItemKind, label: SharedString, - icon: Option, + icon: Option, shortcut: Option, handler: Option, disabled: bool, @@ -86,8 +86,11 @@ impl MenuItem { } /// Shows an icon before the label. - pub fn icon(mut self, role: IconRole) -> Self { - self.icon = Some(role); + pub fn icon(mut self, icon: I) -> Self + where + I: Into, + { + self.icon = Some(icon.into()); self } @@ -526,7 +529,7 @@ fn render_item( let destructive = item.destructive; let checked = item.checked; let shortcut = item.shortcut.clone(); - let icon_role = item.icon; + let icon = item.icon.clone(); div() .id(("menu-item", index)) @@ -576,8 +579,8 @@ fn render_item( } } }) - .when_some(icon_role, |row, role| { - row.child(icon_glyph(role).size(chrome.icon_size).color(label_color)) + .when_some(icon, |row, icon| { + row.child(icon.size(chrome.icon_size).color(label_color)) }) .child( div() diff --git a/crates/core/src/segmented.rs b/crates/core/src/segmented.rs index 7e52a82..2ff1957 100644 --- a/crates/core/src/segmented.rs +++ b/crates/core/src/segmented.rs @@ -7,7 +7,7 @@ use gpui::{ SpringConfig, StatefulInteractiveElement as _, StyleRefinement, Styled as _, Toggled, Window, canvas, div, }; -use gpui_native_icons::{IconRole, icon}; +use gpui_native_icons::Icon; use gpui_native_theme::{ActiveTheme as _, Size}; use gpui_native_utils::{focus_handle, focus_ring}; @@ -21,7 +21,7 @@ type ChangeHandler = Rc; pub struct SegmentedOption { value: T, label: SharedString, - icon: Option, + icon: Option, } impl SegmentedOption { @@ -38,8 +38,11 @@ impl SegmentedOption { } /// Sets a leading icon for the segment. - pub fn icon(mut self, role: IconRole) -> Self { - self.icon = Some(role); + pub fn icon(mut self, icon: I) -> Self + where + I: Into, + { + self.icon = Some(icon.into()); self } } @@ -226,8 +229,8 @@ where .role(Role::RadioButton) .aria_label(segment.label.clone()) .aria_toggled(Toggled::from(is_selected)) - .when_some(segment.icon, |segment, role| { - segment.child(icon(role).size(icon_size).color(content_color)) + .when_some(segment.icon.clone(), |segment, icon| { + segment.child(icon.size(icon_size).color(content_color)) }) .child(segment.label.clone()) .child( diff --git a/crates/core/src/select.rs b/crates/core/src/select.rs index cddaca5..1a72b09 100644 --- a/crates/core/src/select.rs +++ b/crates/core/src/select.rs @@ -5,7 +5,7 @@ use gpui::{ App, ElementId, InteractiveElement as _, IntoElement, ParentElement as _, RenderOnce, Role, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled as _, Window, }; -use gpui_native_icons::{IconRole, icon}; +use gpui_native_icons::{Icon, IconRole, icon}; use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, Size}; use gpui_native_utils::focus_ring; @@ -20,7 +20,7 @@ pub type SelectHandler = Rc, &mut Window, &mut App)>; pub struct SelectOption { value: T, label: SharedString, - icon: Option, + icon: Option, } impl SelectOption { @@ -37,8 +37,11 @@ impl SelectOption { } /// Sets a leading icon for the option's row. - pub fn icon(mut self, role: IconRole) -> Self { - self.icon = Some(role); + pub fn icon(mut self, icon: I) -> Self + where + I: Into, + { + self.icon = Some(icon.into()); self } } @@ -272,8 +275,8 @@ where }; let mut row = MenuItem::new(option.label.clone()); - if let Some(role) = option.icon { - row = row.icon(role); + if let Some(icon) = option.icon.clone() { + row = row.icon(icon); } row.checked(is_selected).handler(handler) }) diff --git a/crates/core/src/toast.rs b/crates/core/src/toast.rs index c0e8045..f42470d 100644 --- a/crates/core/src/toast.rs +++ b/crates/core/src/toast.rs @@ -9,7 +9,7 @@ use gpui::{ IntoElement, ParentElement as _, RenderOnce, SharedString, StatefulInteractiveElement as _, Styled as _, Task, Window, deferred, div, ease_out_quint, px, }; -use gpui_native_icons::{IconRole, icon}; +use gpui_native_icons::{Icon, IconRole, icon}; use gpui_native_theme::ActiveTheme as _; use crate::button::{Button, ButtonVariant}; @@ -43,7 +43,7 @@ impl ToastAction { #[derive(Clone)] pub struct Toast { message: SharedString, - icon: Option, + icon: Option, action: Option, duration: Option, on_dismiss: Option, @@ -68,8 +68,11 @@ impl Toast { } /// Shows this glyph before the content. - pub fn icon(mut self, role: IconRole) -> Self { - self.icon = Some(role); + pub fn icon(mut self, icon: I) -> Self + where + I: Into, + { + self.icon = Some(icon.into()); self } @@ -285,14 +288,14 @@ fn pill(entry: &ToastEntry, toasts: &Entity, cx: &App) -> gpui::Stateful .border_color(border) .rounded(radius) .shadow(shadows.clone()) - .when_some(toast.icon, |pill, icon_role| { + .when_some(toast.icon.clone(), |pill, icon| { pill.child( div() .flex_none() .mr_2() .flex() .items_center() - .child(icon(icon_role).size(icon_size).color(foreground)), + .child(icon.size(icon_size).color(foreground)), ) }) .child( diff --git a/crates/data/src/list.rs b/crates/data/src/list.rs index ed76103..07437f8 100644 --- a/crates/data/src/list.rs +++ b/crates/data/src/list.rs @@ -6,7 +6,7 @@ use gpui::{ ParentElement as _, RenderOnce, Role, SharedString, StatefulInteractiveElement as _, Styled as _, Window, div, }; -use gpui_native_icons::{IconRole, icon}; +use gpui_native_icons::Icon; use gpui_native_theme::ActiveTheme as _; use gpui_native_utils::{nav_key, paint_selectable_row, selectable_label_color}; @@ -17,7 +17,7 @@ type ItemsFn = Box Vec>; /// One row of a [`List`]. pub struct ListItem { label: SharedString, - icon: Option, + icon: Option, detail: Option, disabled: bool, } @@ -36,9 +36,12 @@ impl ListItem { } } - /// Shows an icon leading the label. - pub fn icon(mut self, role: IconRole) -> Self { - self.icon = Some(role); + /// Shows an icon leading the label, a role or any custom [`IconNamed`]. + pub fn icon(mut self, icon: I) -> Self + where + I: Into, + { + self.icon = Some(icon.into()); self } @@ -222,12 +225,8 @@ fn render_row( } }) }) - .when_some(item.icon, |row, role| { - row.child( - icon(role) - .size(theme.sizes.medium.icon_size) - .color(label_color), - ) + .when_some(item.icon.clone(), |row, icon| { + row.child(icon.size(theme.sizes.medium.icon_size).color(label_color)) }) .child( div() diff --git a/crates/data/src/sidebar.rs b/crates/data/src/sidebar.rs index 4046b60..e15103f 100644 --- a/crates/data/src/sidebar.rs +++ b/crates/data/src/sidebar.rs @@ -10,7 +10,7 @@ use gpui::{ Styled as _, Window, div, }; use gpui_native_core::scroll_area::ScrollArea; -use gpui_native_icons::{IconRole, icon}; +use gpui_native_icons::Icon; use gpui_native_theme::ActiveTheme as _; use gpui_native_utils::{nav_key, paint_selectable_row, selectable_label_color}; @@ -26,7 +26,7 @@ pub enum SidebarEntry { /// A selectable row. Row { label: SharedString, - icon: Option, + icon: Option, detail: Option, disabled: bool, }, @@ -56,10 +56,13 @@ impl SidebarEntry { } } - /// Shows an icon leading the label. - pub fn icon(mut self, role: IconRole) -> Self { - if let Self::Row { icon, .. } = &mut self { - *icon = Some(role); + /// Shows an icon leading the label, a role or any custom [`IconNamed`]. + pub fn icon(mut self, icon: I) -> Self + where + I: Into, + { + if let Self::Row { icon: slot, .. } = &mut self { + *slot = Some(icon.into()); } self } @@ -337,12 +340,8 @@ fn render_entry( }); } - row = row.when_some(*row_icon, |row, role| { - row.child( - icon(role) - .size(theme.sizes.medium.icon_size) - .color(label_color), - ) + row = row.when_some(row_icon.clone(), |row, icon| { + row.child(icon.size(theme.sizes.medium.icon_size).color(label_color)) }); row = row.child(div().flex_1().text_color(label_color).child(label.clone())); diff --git a/crates/data/src/tabs.rs b/crates/data/src/tabs.rs index 51333cb..48f22ca 100644 --- a/crates/data/src/tabs.rs +++ b/crates/data/src/tabs.rs @@ -10,7 +10,7 @@ use gpui_native_core::button::{Button, ButtonVariant}; use gpui_native_core::common::Sizable as _; use gpui_native_core::spinner::Spinner; use gpui_native_core::tooltip::Tooltip; -use gpui_native_icons::{IconRole, icon}; +use gpui_native_icons::{Icon, IconRole}; use gpui_native_theme::{ActiveTheme as _, Size}; use gpui_native_utils::adjacent_selectable; @@ -35,7 +35,7 @@ pub struct Tab { /// The stable identity for element state; must survive reordering. key: ElementId, label: SharedString, - icon: Option, + icon: Option, lead: Option, busy: bool, disabled: bool, @@ -63,8 +63,11 @@ impl Tab { } /// Shows an icon leading the label. - pub fn icon(mut self, role: IconRole) -> Self { - self.icon = Some(role); + pub fn icon(mut self, icon: I) -> Self + where + I: Into, + { + self.icon = Some(icon.into()); self } @@ -344,18 +347,18 @@ fn render_tab( }; let lead = tab.lead.take(); + let icon = tab.icon.take(); strip_tab = match lead { Some(lead) => strip_tab.child(lead), None => match variant { - TabsVariant::Underline => strip_tab.child(div().flex_none().pb_0p5().when_some( - tab.icon, - |slot, role| { - slot.child(icon(role).size(metrics.dense_icon_size).color(label_color)) - }, - )), - TabsVariant::Pill => strip_tab.when_some(tab.icon, |strip_tab, role| { - strip_tab.child(icon(role).size(metrics.dense_icon_size).color(label_color)) + TabsVariant::Underline => { + strip_tab.child(div().flex_none().pb_0p5().when_some(icon, |slot, icon| { + slot.child(icon.size(metrics.dense_icon_size).color(label_color)) + })) + } + TabsVariant::Pill => strip_tab.when_some(icon, |strip_tab, icon| { + strip_tab.child(icon.size(metrics.dense_icon_size).color(label_color)) }), }, }; diff --git a/crates/data/src/tree.rs b/crates/data/src/tree.rs index d653f82..168bb10 100644 --- a/crates/data/src/tree.rs +++ b/crates/data/src/tree.rs @@ -6,7 +6,7 @@ use gpui::{ ParentElement as _, RenderOnce, Role, SharedString, StatefulInteractiveElement as _, Styled as _, Window, div, }; -use gpui_native_icons::{IconRole, icon}; +use gpui_native_icons::{Icon, IconRole, icon}; use gpui_native_theme::ActiveTheme as _; use gpui_native_utils::{nav_key, paint_selectable_row, selectable_label_color}; @@ -20,7 +20,7 @@ type ItemsFn = Box Vec>; #[derive(Clone)] pub struct TreeItem { label: SharedString, - icon: Option, + icon: Option, depth: usize, expandable: bool, expanded: bool, @@ -44,8 +44,11 @@ impl TreeItem { } /// Shows an icon leading the label. - pub fn icon(mut self, role: IconRole) -> Self { - self.icon = Some(role); + pub fn icon(mut self, icon: I) -> Self + where + I: Into, + { + self.icon = Some(icon.into()); self } @@ -317,12 +320,8 @@ fn render_row( selected, on_disclosure, )) - .when_some(item.icon, |row, role| { - row.child( - icon(role) - .size(theme.sizes.medium.icon_size) - .color(label_color), - ) + .when_some(item.icon.clone(), |row, icon| { + row.child(icon.size(theme.sizes.medium.icon_size).color(label_color)) }) .child( div() diff --git a/crates/icons/Cargo.toml b/crates/icons/Cargo.toml index f8f0cf4..27a3978 100644 --- a/crates/icons/Cargo.toml +++ b/crates/icons/Cargo.toml @@ -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" diff --git a/crates/icons/src/lib.rs b/crates/icons/src/lib.rs index 8f5d6c3..90ca607 100644 --- a/crates/icons/src/lib.rs +++ b/crates/icons/src/lib.rs @@ -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, transformation: Option, @@ -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(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 { - 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 { + 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, + transformation: Option, + 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, 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, + transformation: Option, + 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 From 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. diff --git a/crates/layout/src/dock.rs b/crates/layout/src/dock.rs index 5cc8a4c..7f453e7 100644 --- a/crates/layout/src/dock.rs +++ b/crates/layout/src/dock.rs @@ -15,7 +15,7 @@ use gpui_native_core::common::{h_flex, v_flex}; use gpui_native_core::context_menu::ContextMenu; use gpui_native_core::menu::MenuItem; use gpui_native_data::tabs::{Tab, Tabs, TabsVariant}; -use gpui_native_icons::IconRole; +use gpui_native_icons::{Icon, IconRole}; use gpui_native_theme::{ActiveTheme as _, Size}; use crate::resize_handle::ResizeHandle; @@ -35,7 +35,7 @@ type OpenRightSidebarHandler = Rc; pub struct PanelDescriptor { id: SharedString, title: SharedString, - icon: Option, + icon: Option, build: PanelBuilder, right_sidebar: Option, } @@ -58,8 +58,11 @@ impl PanelDescriptor { } /// Shows an icon leading the tab label. - pub fn icon(mut self, role: IconRole) -> Self { - self.icon = Some(role); + pub fn icon(mut self, icon: I) -> Self + where + I: Into, + { + self.icon = Some(icon.into()); self } @@ -130,7 +133,7 @@ impl Render for DragPreview { struct PanelTab { panel: SharedString, title: SharedString, - icon: Option, + icon: Option, view: AnyView, right_view: Option, } @@ -215,7 +218,7 @@ impl DockState { let tab = PanelTab { panel: SharedString::from(id), title: panel.title.clone(), - icon: panel.icon, + icon: panel.icon.clone(), view: (panel.build)(window, cx), right_view, }; @@ -679,7 +682,7 @@ impl DockState { .map(|tab| TabView { panel: tab.panel.clone(), title: tab.title.clone(), - icon: tab.icon, + icon: tab.icon.clone(), view: tab.view.clone(), }) .collect(), @@ -747,7 +750,7 @@ fn take_node(node: &mut Node) -> Node { struct TabView { panel: SharedString, title: SharedString, - icon: Option, + icon: Option, view: AnyView, } @@ -1039,8 +1042,8 @@ fn render_pane( tabs.iter() .map(|tab| { let mut entry = Tab::new(tab.panel.clone(), tab.title.clone()); - if let Some(role) = tab.icon { - entry = entry.icon(role); + if let Some(icon) = tab.icon.clone() { + entry = entry.icon(icon); } entry.closeable(true).wrap({ let panel = tab.panel.clone(); diff --git a/docs/CHECKLIST.md b/docs/CHECKLIST.md index 8bef9bc..dc81754 100644 --- a/docs/CHECKLIST.md +++ b/docs/CHECKLIST.md @@ -61,6 +61,9 @@ Gaps surfaced by the signed comparison; ordered by signed's usage. - [x] `IconRole` + bundled Lucide fallback, Segoe Fluent codepoints (Windows), freedesktop theme walker (Linux/Omarchy) +- [x] `IconNamed` — app-defined icon sets: implement the trait for an enum of + asset paths, render it with `Icon::named`, and hand it to any icon + builder (`impl Into`) alongside the built-in roles ### core (`crates/core`)