From dfe1e9f42c86e59b363855466e7e7a639ca0c481 Mon Sep 17 00:00:00 2001 From: Amit Verma Date: Sun, 4 Oct 2026 14:26:23 +0530 Subject: [PATCH] =?UTF-8?q?Primitives=20=C2=B7=20Icon::from=5Fpath=20draws?= =?UTF-8?q?=20an=20application's=20SVG=20with=20the=20theme's=20size=20and?= =?UTF-8?q?=20color;=20a=20path=20the=20asset=20source=20lacks=20fails=20a?= =?UTF-8?q?t=20render?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/assets/mark.svg | 4 ++ examples/docs/icons.rs | 36 ++++++++++-- examples/gallery/assets.rs | 25 +++++++++ examples/gallery/main.rs | 5 +- examples/gallery/pages/primitives/parts.rs | 12 +++- examples/gallery/web.rs | 7 ++- frontend/src/docs/icons.md | 7 +++ src/primitives/icon.rs | 65 ++++++++++++++++++++-- tasks/github.md | 1 + 9 files changed, 145 insertions(+), 17 deletions(-) create mode 100644 examples/assets/mark.svg create mode 100644 examples/gallery/assets.rs diff --git a/examples/assets/mark.svg b/examples/assets/mark.svg new file mode 100644 index 0000000..bbaec46 --- /dev/null +++ b/examples/assets/mark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/examples/docs/icons.rs b/examples/docs/icons.rs index 7c59cd0..a8b2694 100644 --- a/examples/docs/icons.rs +++ b/examples/docs/icons.rs @@ -1,4 +1,4 @@ -use std::f32::consts::FRAC_PI_4; +use std::{borrow::Cow, f32::consts::FRAC_PI_4}; use ely_gpui_component::{ Assets, @@ -7,10 +7,31 @@ use ely_gpui_component::{ theme::{ActiveTheme, IconSize}, }; use gpui::{ - App, AppContext, Context, FocusHandle, IntoElement, ParentElement, Radians, Render, Styled, - Window, WindowOptions, div, + App, AppContext, AssetSource, Context, FocusHandle, IntoElement, ParentElement, Radians, + Render, SharedString, Styled, Window, WindowOptions, div, }; +const CUSTOM_ICON: &str = "app-icons/mark.svg"; + +struct AppAssets; + +impl AssetSource for AppAssets { + fn load(&self, path: &str) -> gpui::Result>> { + if path == CUSTOM_ICON { + return Ok(Some(Cow::Borrowed(include_bytes!("../assets/mark.svg")))); + } + Assets.load(path) + } + + fn list(&self, path: &str) -> gpui::Result> { + let mut assets = Assets.list(path)?; + if CUSTOM_ICON.starts_with(path) { + assets.push(CUSTOM_ICON.into()); + } + Ok(assets) + } +} + struct Icons { focus: FocusHandle, } @@ -43,7 +64,12 @@ impl Render for Icons { .child(Icon::new(IconName::Check)) .child(Icon::new(IconName::Rocket).size(IconSize::Xl)) .child(Icon::new(IconName::CircleCheck).color(theme.colors.success)) - .child(Icon::new(IconName::Package).rotate(Radians(FRAC_PI_4))), + .child(Icon::new(IconName::Package).rotate(Radians(FRAC_PI_4))) + .child( + Icon::from_path(CUSTOM_ICON) + .size(IconSize::Xl) + .color(theme.colors.accent), + ), ) .child( div() @@ -59,7 +85,7 @@ impl Render for Icons { fn main() { gpui_platform::application() - .with_assets(Assets) + .with_assets(AppAssets) .run(|cx: &mut App| { ely_gpui_component::init(cx); IconTheme::default() diff --git a/examples/gallery/assets.rs b/examples/gallery/assets.rs new file mode 100644 index 0000000..edc45ea --- /dev/null +++ b/examples/gallery/assets.rs @@ -0,0 +1,25 @@ +use std::borrow::Cow; + +use ely_gpui_component::Assets; +use gpui::{AssetSource, SharedString}; + +pub const CUSTOM_ICON: &str = "app-icons/mark.svg"; + +pub struct GalleryAssets; + +impl AssetSource for GalleryAssets { + fn load(&self, path: &str) -> gpui::Result>> { + if path == CUSTOM_ICON { + return Ok(Some(Cow::Borrowed(include_bytes!("../assets/mark.svg")))); + } + Assets.load(path) + } + + fn list(&self, path: &str) -> gpui::Result> { + let mut assets = Assets.list(path)?; + if CUSTOM_ICON.starts_with(path) { + assets.push(CUSTOM_ICON.into()); + } + Ok(assets) + } +} diff --git a/examples/gallery/main.rs b/examples/gallery/main.rs index a50dae6..fa60570 100644 --- a/examples/gallery/main.rs +++ b/examples/gallery/main.rs @@ -20,6 +20,7 @@ macro_rules! asset { }; } +mod assets; #[cfg(not(target_family = "wasm"))] mod capture; mod pages; @@ -42,7 +43,7 @@ use std::path::PathBuf; #[cfg(not(target_family = "wasm"))] use anyhow::{Context as _, Result, bail}; #[cfg(not(target_family = "wasm"))] -use ely_gpui_component::Assets; +use assets::GalleryAssets; use ely_gpui_component::theme::{Mode, Theme}; use gpui::{ App, AppContext, Bounds, KeyBinding, Menu, MenuItem, SharedString, TitlebarOptions, @@ -201,7 +202,7 @@ fn main() -> Result<()> { // A script aims across its page; stories hold part. let scripted = start.narrow.is_none() && start.story.is_none(); gpui_platform::application() - .with_assets(Assets) + .with_assets(GalleryAssets) .run(move |cx: &mut App| { let window = launch(start, cx); if let Some(dir) = capture { diff --git a/examples/gallery/pages/primitives/parts.rs b/examples/gallery/pages/primitives/parts.rs index 60de628..e988510 100644 --- a/examples/gallery/pages/primitives/parts.rs +++ b/examples/gallery/pages/primitives/parts.rs @@ -14,6 +14,7 @@ use gpui::{ }; use crate::{ + assets::CUSTOM_ICON, probe::probe, ui::{UNREAD, blocked, code, picture, row, section, specimen, specimens}, }; @@ -44,7 +45,7 @@ pub fn icon(cx: &App) -> impl IntoElement + use<> { ]; section( "Icon", - "Lucide, drawn as a mask. Color comes from the theme.", + "Bundled Lucide or an application's SVG asset, drawn as a themed mask.", cx, ) .child(specimens().children( @@ -55,6 +56,15 @@ pub fn icon(cx: &App) -> impl IntoElement + use<> { specimen(name, Icon::new(IconName::CircleCheck).color(color), cx) })), ) + .child( + specimens().child(specimen( + "custom SVG", + Icon::from_path(CUSTOM_ICON) + .size(IconSize::Xl) + .color(colors.accent), + cx, + )), + ) } pub fn icon_set(cx: &App) -> impl IntoElement + use<> { diff --git a/examples/gallery/web.rs b/examples/gallery/web.rs index 2237a38..73a6132 100644 --- a/examples/gallery/web.rs +++ b/examples/gallery/web.rs @@ -6,14 +6,15 @@ use std::{ }; use anyhow::{Context as _, Result, anyhow, bail}; -use ely_gpui_component::Assets; use gpui::{Application, AsyncApp, SharedString, WindowHandle}; use gpui_web::{CanvasFontFallback, WebBackendPreference, WebPlatform}; use wasm_bindgen::prelude::*; use web_sys::UrlSearchParams; use crate::{ - Start, choose, launch, pages, + Start, + assets::GalleryAssets, + choose, launch, pages, shell::{Choice, Gallery}, }; @@ -97,7 +98,7 @@ pub fn start() { let http = Arc::new(platform.fetch_http_client()); Application::with_platform(platform) .with_http_client(http) - .with_assets(Assets) + .with_assets(GalleryAssets) .run(move |cx| { cx.text_system() .add_fonts(vec![Cow::Borrowed(HEBREW)]) diff --git a/frontend/src/docs/icons.md b/frontend/src/docs/icons.md index 26693a3..75f9a22 100644 --- a/frontend/src/docs/icons.md +++ b/frontend/src/docs/icons.md @@ -9,6 +9,7 @@ Ely bundles 306 Lucide 1.48.0 icons, each an `IconName` variant: `IconName::Chec | Call | Does | | --- | --- | | `Icon::new(name)` | Draws the icon at `IconSize::Md` in the theme's `fg`. | +| `Icon::from_path(path)` | Draws an SVG from the application's asset source with the same defaults. | | `.size(IconSize::Xl)` | `Xs` to `Xxl`, from the theme's icon sizes. | | `.color(color)` | Any color; components pass theme colors. | | `.rotate(Radians(..))` | Turns the glyph inside its box. | @@ -43,6 +44,12 @@ A name rule beats an extension rule, and both beat Ely's map. GPUI takes one asset source. An app with files of its own writes an `AssetSource` that answers its own paths and hands every other path to `Assets`. `init` panics unless `icons/check.svg` loads through it. +Use `Icon::from_path("app-icons/mark.svg")` to draw one of those SVG assets with Ely's themed sizing and color. It also accepts an owned `String` or `SharedString`. Paths are asset keys resolved by `AssetSource`; a path it lacks panics on first draw. SVGs render as single-color masks; `IconName` and `IconPicker` continue to list the bundled icons. + +Compatible single-color SVGs can come from [Lucide](https://lucide.dev/), [Hugeicons](https://github.com/hugeicons/hugeicons), [Phosphor](https://github.com/phosphor-icons/core), [Remix Icon](https://github.com/Remix-Design/RemixIcon), or [Tabler](https://github.com/tabler/tabler-icons). The app supplies its chosen files and preserves their licenses; Ely does not download or register those catalogs. + +The example below includes its asset source. Copy [`examples/assets/mark.svg`](https://github.com/ZacharyZhang-NY/Ely-GPUI-Components/blob/main/examples/assets/mark.svg) with it, or supply your own SVG. + ## A window of icons ```rust example=icons diff --git a/src/primitives/icon.rs b/src/primitives/icon.rs index 579f9cb..9443b68 100644 --- a/src/primitives/icon.rs +++ b/src/primitives/icon.rs @@ -1,6 +1,8 @@ +use std::collections::HashSet; + use gpui::{ - App, Hsla, IntoElement, Radians, RenderOnce, SharedString, Transformation, Window, prelude::*, - size, svg, + App, AssetSource, Global, Hsla, IntoElement, Radians, RenderOnce, SharedString, Transformation, + Window, prelude::*, size, svg, }; use crate::theme::{ActiveTheme, IconSize}; @@ -343,7 +345,8 @@ icons! { #[derive(IntoElement)] pub struct Icon { - name: IconName, + path: SharedString, + bundled: bool, size: IconSize, color: Option, hover: Option<(SharedString, Hsla)>, @@ -354,7 +357,16 @@ pub struct Icon { impl Icon { pub fn new(name: IconName) -> Self { Self { - name, + bundled: true, + ..Self::from_path(name.path()) + } + } + + /// Draws an SVG supplied by the application's asset source; a missing one fails at render. + pub fn from_path(path: impl Into) -> Self { + Self { + path: path.into(), + bundled: false, size: IconSize::Md, color: None, hover: None, @@ -402,12 +414,32 @@ impl From for Icon { } } +/// App paths already found in the asset source. +#[derive(Default)] +struct FoundIcons(HashSet); + +impl Global for FoundIcons {} + +fn ensure_found(source: &dyn AssetSource, path: &str) { + match source.load(path) { + Ok(Some(_)) => {} + Ok(None) => panic!("ely: icon {path} is not in the app's asset source"), + Err(error) => panic!("ely: icon {path} failed to load: {error:#}"), + } +} + impl RenderOnce for Icon { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + if !self.bundled && !cx.default_global::().0.contains(&self.path) { + ensure_found(cx.asset_source().as_ref(), &self.path); + cx.default_global::() + .0 + .insert(self.path.clone()); + } let theme = cx.theme(); let color = self.color.unwrap_or(theme.colors.fg); svg() - .path(self.name.path()) + .path(self.path) .size(theme.icon_size(self.size)) .flex_none() .text_color(color) @@ -425,7 +457,7 @@ impl RenderOnce for Icon { #[cfg(test)] mod tests { - use super::IconName; + use super::{Icon, IconName, ensure_found}; #[test] fn every_icon_ships_in_the_bundle() { @@ -437,4 +469,25 @@ mod tests { ); } } + + #[test] + fn an_app_path_draws_as_given_and_a_name_as_its_bundled_file() { + let custom = Icon::from_path("app-icons/mark.svg"); + assert_eq!(custom.path.as_ref(), "app-icons/mark.svg"); + assert!(!custom.bundled); + let named = Icon::new(IconName::Check); + assert_eq!(named.path.as_ref(), IconName::Check.path()); + assert!(named.bundled); + } + + #[test] + fn a_path_the_source_holds_is_found() { + ensure_found(&crate::Assets, IconName::Check.path()); + } + + #[test] + #[should_panic(expected = "ely: icon app-icons/missing.svg is not in the app's asset source")] + fn a_path_the_source_lacks_fails() { + ensure_found(&crate::Assets, "app-icons/missing.svg"); + } } diff --git a/tasks/github.md b/tasks/github.md index 573c071..af67251 100644 --- a/tasks/github.md +++ b/tasks/github.md @@ -4,3 +4,4 @@ - [x] G2 Issue #2: tree guides line up under the chevron and run unbroken across rows - [x] G3 Issue #3: Checkbox, Switch, Radio, Slider and Select name their role, label and state - [x] G4 Push, comment on #2 and #3, close #2 +- [x] G5 PR #4: `Icon::from_path` draws an app's SVG; a path the asset source lacks fails at render