Skip to main content

gpui_kit_sf_symbols/
lib.rs

1//! Apple [SF Symbols](https://developer.apple.com/sf-symbols/) for
2//! [GPUI Kit](https://gpui-kit.com).
3//!
4//! [`SfSymbol`] plugs into GPUI Kit's `Icon` through the [`IconNamed`] trait,
5//! and [`SfSymbolAssets`] serves the matching SVGs. Symbols are not bundled
6//! (Apple's license does not allow it): they are rendered as vector paths from
7//! the running system, so they look exactly like the OS's own, in any of the
8//! nine weights, and tint with the text color like every other GPUI icon.
9//!
10//! ```no_run
11//! use gpui_kit::*;
12//! use gpui_kit::assets::Assets;
13//! use gpui_kit::component::Icon;
14//! use gpui_kit_sf_symbols::{SfSymbol, SfSymbolAssets, Weight};
15//!
16//! const GEAR: SfSymbol = SfSymbol::from_static("gearshape");
17//!
18//! struct Example;
19//!
20//! impl Render for Example {
21//!     fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
22//!         div()
23//!             .flex()
24//!             .gap_2()
25//!             .child(Icon::new(GEAR))                                     // regular
26//!             .child(Icon::new(SfSymbol::new("heart.fill").bold()))
27//!             .child(Icon::new(SfSymbol::new("wifi").variable_value(0.5)))
28//!             .child(SfSymbol::new("folder.badge.plus").hierarchical())   // no `Icon` needed
29//!     }
30//! }
31//!
32//! fn main() {
33//!     // SF Symbols first, then GPUI Kit's own component icons.
34//!     gpui_kit::application()
35//!         .with_assets(SfSymbolAssets.with(Assets))
36//!         .run(|cx| { /* ... */ });
37//! }
38//! ```
39//!
40//! # Platform support
41//!
42//! Rendering needs macOS 13 or later. Elsewhere, or for a name the system does
43//! not know, a symbol simply draws nothing, the same as any other missing
44//! asset in GPUI; check [`is_available`] or [`SfSymbol::exists`] to choose a
45//! fallback icon. The crate builds on every platform.
46//!
47//! Symbols are rendered with SwiftUI, which only runs on the main thread. GPUI
48//! loads icons there while painting, so this costs nothing in practice. Called
49//! from another thread, [`SfSymbol::svg`] waits for the main thread and gives
50//! up after a couple of seconds if it is busy. Each symbol is rendered once
51//! per style and cached.
52//!
53//! Asset paths have the form `sf-symbols/{style}/{name}.svg`, e.g.
54//! `sf-symbols/bold+hierarchical/folder.badge.plus.svg`, so they never collide
55//! with GPUI Kit's `icons/…` paths.
56
57#[cfg(feature = "gpui-fast")]
58extern crate gpui_fast as gpui;
59
60use std::{
61    borrow::Cow,
62    collections::{HashMap, HashSet},
63    sync::{Arc, LazyLock, Mutex},
64};
65
66use gpui::{
67    AnyElement, App, AssetSource, Element as _, IntoElement, RenderOnce, SharedString, Styled,
68    ViewElement, Window, svg,
69};
70pub use gpui_kit_assets::IconNamed;
71
72// Compile-check the README's examples.
73#[cfg(doctest)]
74#[doc = include_str!("../README.md")]
75struct ReadmeDoctests;
76
77/// Prefix shared by every asset path this crate serves.
78pub const PATH_PREFIX: &str = "sf-symbols/";
79
80/// A symbol weight, matching the system font weights.
81#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)]
82pub enum Weight {
83    UltraLight,
84    Thin,
85    Light,
86    #[default]
87    Regular,
88    Medium,
89    Semibold,
90    Bold,
91    Heavy,
92    Black,
93}
94
95impl Weight {
96    /// Every weight, lightest first.
97    pub const ALL: [Weight; 9] = [
98        Weight::UltraLight,
99        Weight::Thin,
100        Weight::Light,
101        Weight::Regular,
102        Weight::Medium,
103        Weight::Semibold,
104        Weight::Bold,
105        Weight::Heavy,
106        Weight::Black,
107    ];
108
109    /// The lowercase weight name used in asset paths, e.g. `"semibold"`.
110    pub const fn as_str(self) -> &'static str {
111        match self {
112            Weight::UltraLight => "ultralight",
113            Weight::Thin => "thin",
114            Weight::Light => "light",
115            Weight::Regular => "regular",
116            Weight::Medium => "medium",
117            Weight::Semibold => "semibold",
118            Weight::Bold => "bold",
119            Weight::Heavy => "heavy",
120            Weight::Black => "black",
121        }
122    }
123
124    /// Parse a lowercase weight name.
125    pub fn from_name(name: &str) -> Option<Self> {
126        Self::ALL.into_iter().find(|weight| weight.as_str() == name)
127    }
128}
129
130impl std::fmt::Display for Weight {
131    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132        f.write_str(self.as_str())
133    }
134}
135
136impl std::str::FromStr for Weight {
137    type Err = ParseError;
138
139    /// Parse a lowercase weight name, e.g. `"bold"`.
140    fn from_str(name: &str) -> Result<Self, ParseError> {
141        Self::from_name(name).ok_or(ParseError(()))
142    }
143}
144
145/// How a symbol's layers are drawn. Both come out in a single color, so the
146/// icon still follows the text color.
147#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)]
148pub enum Rendering {
149    /// Every layer fully opaque.
150    #[default]
151    Monochrome,
152    /// Secondary layers at reduced opacity, e.g. the folder behind a badge.
153    Hierarchical,
154}
155
156/// Returned when parsing an unknown weight name.
157#[derive(Clone, Copy, Debug, Eq, PartialEq)]
158pub struct ParseError(());
159
160impl std::fmt::Display for ParseError {
161    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162        f.write_str("unknown SF Symbols weight name")
163    }
164}
165
166impl std::error::Error for ParseError {}
167
168/// An SF Symbol by name, e.g. `"gearshape"` or `"folder.badge.plus"`, with
169/// its weight, rendering mode and variable value.
170///
171/// Names are the ones shown in Apple's SF Symbols app. Which names exist
172/// depends on the macOS version; see [`SfSymbol::exists`].
173///
174/// `SfSymbol` is `Copy`: names given at run time are interned (each distinct
175/// name is kept for the life of the program).
176#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
177pub struct SfSymbol {
178    name: &'static str,
179    weight: Weight,
180    rendering: Rendering,
181    /// Percent, for symbols with variable color such as `wifi`.
182    variable_value: Option<u8>,
183}
184
185impl SfSymbol {
186    /// A symbol in the regular weight.
187    pub fn new(name: &str) -> Self {
188        static NAMES: LazyLock<Mutex<HashSet<&'static str>>> = LazyLock::new(Default::default);
189
190        let mut names = NAMES.lock().unwrap();
191        let name = match names.get(name) {
192            Some(name) => name,
193            None => {
194                let name: &'static str = Box::leak(name.into());
195                names.insert(name);
196                name
197            }
198        };
199        Self::from_static(name)
200    }
201
202    /// Like [`SfSymbol::new`], without interning; usable in constants.
203    pub const fn from_static(name: &'static str) -> Self {
204        Self {
205            name,
206            weight: Weight::Regular,
207            rendering: Rendering::Monochrome,
208            variable_value: None,
209        }
210    }
211
212    /// The symbol's name, e.g. `"gearshape"`.
213    pub const fn name(self) -> &'static str {
214        self.name
215    }
216
217    pub const fn get_weight(self) -> Weight {
218        self.weight
219    }
220
221    pub const fn get_rendering(self) -> Rendering {
222        self.rendering
223    }
224
225    /// The variable value, if one was set, from 0.0 to 1.0.
226    pub fn get_variable_value(self) -> Option<f32> {
227        self.variable_value.map(|percent| f32::from(percent) / 100.)
228    }
229
230    /// This symbol in the given weight.
231    pub fn weight(mut self, weight: Weight) -> Self {
232        self.weight = weight;
233        self
234    }
235
236    pub fn ultralight(self) -> Self {
237        self.weight(Weight::UltraLight)
238    }
239
240    pub fn thin(self) -> Self {
241        self.weight(Weight::Thin)
242    }
243
244    pub fn light(self) -> Self {
245        self.weight(Weight::Light)
246    }
247
248    pub fn regular(self) -> Self {
249        self.weight(Weight::Regular)
250    }
251
252    pub fn medium(self) -> Self {
253        self.weight(Weight::Medium)
254    }
255
256    pub fn semibold(self) -> Self {
257        self.weight(Weight::Semibold)
258    }
259
260    pub fn bold(self) -> Self {
261        self.weight(Weight::Bold)
262    }
263
264    pub fn heavy(self) -> Self {
265        self.weight(Weight::Heavy)
266    }
267
268    pub fn black(self) -> Self {
269        self.weight(Weight::Black)
270    }
271
272    /// This symbol with the given rendering mode.
273    pub fn rendering(mut self, rendering: Rendering) -> Self {
274        self.rendering = rendering;
275        self
276    }
277
278    /// Draw secondary layers at reduced opacity.
279    pub fn hierarchical(self) -> Self {
280        self.rendering(Rendering::Hierarchical)
281    }
282
283    /// Draw every layer fully opaque (the default).
284    pub fn monochrome(self) -> Self {
285        self.rendering(Rendering::Monochrome)
286    }
287
288    /// For symbols with variable color, such as `wifi` or `speaker.wave.3`:
289    /// how much of the symbol is "on", from 0.0 to 1.0 (rounded to whole
290    /// percent). Layers past the value are drawn faded. Other symbols ignore it.
291    pub fn variable_value(mut self, value: f32) -> Self {
292        self.variable_value = Some((value.clamp(0., 1.) * 100.).round() as u8);
293        self
294    }
295
296    /// The asset path, e.g. `sf-symbols/bold+hierarchical+v50/wifi.svg`.
297    pub fn path(self) -> SharedString {
298        let mut path = format!("{PATH_PREFIX}{}", self.weight);
299        if self.rendering == Rendering::Hierarchical {
300            path.push_str("+hierarchical");
301        }
302        if let Some(percent) = self.variable_value {
303            path.push_str(&format!("+v{percent}"));
304        }
305        path.push('/');
306        path.push_str(self.name);
307        path.push_str(".svg");
308        path.into()
309    }
310
311    /// Parse an asset path produced by [`SfSymbol::path`].
312    pub fn from_path(path: &str) -> Option<Self> {
313        let rest = path.strip_prefix(PATH_PREFIX)?.strip_suffix(".svg")?;
314        let (style, name) = rest.split_once('/')?;
315        if name.is_empty() || name.contains('/') {
316            return None;
317        }
318        let mut parts = style.split('+');
319        let mut symbol = Self::new(name).weight(Weight::from_name(parts.next()?)?);
320        for part in parts {
321            if part == "hierarchical" && symbol.rendering == Rendering::Monochrome {
322                symbol.rendering = Rendering::Hierarchical;
323            } else if let Some(percent) = part.strip_prefix('v')
324                && symbol.variable_value.is_none()
325            {
326                symbol.variable_value = Some(percent.parse().ok().filter(|p| *p <= 100)?);
327            } else {
328                return None;
329            }
330        }
331        Some(symbol)
332    }
333
334    /// Whether the system has a symbol with this name.
335    pub fn exists(self) -> bool {
336        shim::exists(self.name)
337    }
338
339    /// The symbol as an SVG document, or `None` if it cannot be rendered (see
340    /// the [crate docs](crate#platform-support)).
341    ///
342    /// Useful with `Icon::default().data(..)` or `svg().data(..)` when no
343    /// asset source is wanted. Results are cached.
344    pub fn svg(self) -> Option<Arc<[u8]>> {
345        type Cache = HashMap<SfSymbol, Option<Arc<[u8]>>>;
346        static CACHE: LazyLock<Mutex<Cache>> = LazyLock::new(Default::default);
347
348        if let Some(cached) = CACHE.lock().unwrap().get(&self) {
349            return cached.clone();
350        }
351        // Not under the lock: rendering may wait on the main thread, which
352        // may itself be waiting for the lock.
353        let svg = shim::render(self);
354        // A symbol that exists but failed to render (the main thread was busy)
355        // is retried next time.
356        if svg.is_some() || !self.exists() {
357            CACHE.lock().unwrap().insert(self, svg.clone());
358        }
359        svg
360    }
361}
362
363/// Whether SF Symbols can be rendered here: macOS 13 or later.
364pub fn is_available() -> bool {
365    shim::available()
366}
367
368impl From<&'static str> for SfSymbol {
369    fn from(name: &'static str) -> Self {
370        Self::from_static(name)
371    }
372}
373
374impl From<&String> for SfSymbol {
375    fn from(name: &String) -> Self {
376        Self::new(name)
377    }
378}
379
380impl From<&SharedString> for SfSymbol {
381    fn from(name: &SharedString) -> Self {
382        Self::new(name)
383    }
384}
385
386impl std::fmt::Display for SfSymbol {
387    /// The symbol's name, e.g. `gearshape`.
388    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
389        f.write_str(self.name)
390    }
391}
392
393impl IconNamed for SfSymbol {
394    fn path(self) -> SharedString {
395        SfSymbol::path(self)
396    }
397}
398
399// Like `gpui_kit_assets::IconName`, a symbol can be used directly as a child,
400// sized and colored by the surrounding text style. Wrap it in
401// `gpui_kit::component::Icon` for themed sizes and transformations.
402//
403// Written out instead of `#[derive(IntoElement)]` so the impls don't depend on
404// how the derive spells GPUI's crate path; this mirrors what it generates.
405impl IntoElement for SfSymbol {
406    type Element = ViewElement<Self>;
407
408    #[track_caller]
409    fn into_element(self) -> Self::Element {
410        ViewElement::new(self)
411    }
412
413    #[track_caller]
414    fn into_any_element(self) -> AnyElement {
415        self.into_element().into_any()
416    }
417}
418
419impl RenderOnce for SfSymbol {
420    fn render(self, window: &mut Window, _: &mut App) -> impl IntoElement {
421        let text_style = window.text_style();
422        svg()
423            .path(self.path())
424            .flex_shrink_0()
425            .size(text_style.font_size.to_pixels(window.rem_size()))
426            .text_color(text_style.color)
427    }
428}
429
430impl From<SfSymbol> for AnyElement {
431    fn from(symbol: SfSymbol) -> Self {
432        symbol.into_any_element()
433    }
434}
435
436/// Renders any `sf-symbols/…` path from the system.
437///
438/// Combine it with GPUI Kit's own `Assets` (or any other source) using
439/// [`SfSymbolAssets::with`]:
440///
441/// ```no_run
442/// # use gpui_kit_sf_symbols::SfSymbolAssets;
443/// gpui_kit::application().with_assets(SfSymbolAssets.with(gpui_kit::assets::Assets));
444/// ```
445#[derive(Clone, Copy, Debug, Default)]
446pub struct SfSymbolAssets;
447
448impl SfSymbolAssets {
449    /// Serve SF Symbols first and fall back to `fallback` for other paths.
450    pub fn with<B: AssetSource>(self, fallback: B) -> Layered<Self, B> {
451        Layered(self, fallback)
452    }
453}
454
455impl AssetSource for SfSymbolAssets {
456    fn load(&self, path: &str) -> gpui::Result<Option<Cow<'static, [u8]>>> {
457        Ok(SfSymbol::from_path(path)
458            .and_then(|symbol| symbol.svg())
459            .map(|svg| Cow::Owned(svg.to_vec())))
460    }
461
462    /// The system's symbols cannot be enumerated, so this lists nothing.
463    fn list(&self, _path: &str) -> gpui::Result<Vec<SharedString>> {
464        Ok(Vec::new())
465    }
466}
467
468/// Two asset sources layered together: `load` tries the first source, then the
469/// second; `list` merges both.
470#[derive(Clone, Copy, Debug, Default)]
471pub struct Layered<A, B>(pub A, pub B);
472
473impl<A, B> Layered<A, B> {
474    /// Add another fallback below the existing ones.
475    pub fn with<C: AssetSource>(self, fallback: C) -> Layered<Self, C> {
476        Layered(self, fallback)
477    }
478}
479
480impl<A: AssetSource, B: AssetSource> AssetSource for Layered<A, B> {
481    fn load(&self, path: &str) -> gpui::Result<Option<Cow<'static, [u8]>>> {
482        match self.0.load(path)? {
483            Some(bytes) => Ok(Some(bytes)),
484            None => self.1.load(path),
485        }
486    }
487
488    fn list(&self, path: &str) -> gpui::Result<Vec<SharedString>> {
489        let mut paths = self.0.list(path)?;
490        paths.extend(self.1.list(path)?);
491        paths.sort();
492        paths.dedup();
493        Ok(paths)
494    }
495}
496
497/// The Swift shim compiled by `build.rs` (see `swift/shim.swift`).
498#[cfg(sf_symbols_shim)]
499mod shim {
500    use std::{
501        ffi::{CString, c_char, c_int, c_void},
502        sync::{Arc, mpsc},
503        time::Duration,
504    };
505
506    use crate::{Rendering, SfSymbol};
507
508    /// How long an off-main-thread render waits for the main thread.
509    const MAIN_THREAD_TIMEOUT: Duration = Duration::from_secs(2);
510
511    unsafe extern "C" {
512        /// Main thread only.
513        fn gpui_sf_symbol_svg(
514            name: *const c_char,
515            weight: i32,
516            hierarchical: bool,
517            variable_value: f64,
518            length: *mut usize,
519        ) -> *mut u8;
520        fn gpui_sf_symbol_free(buffer: *mut u8);
521        fn gpui_sf_symbol_exists(name: *const c_char) -> bool;
522        fn gpui_sf_symbol_available() -> bool;
523
524        // libSystem: pthreads and Grand Central Dispatch.
525        fn pthread_main_np() -> c_int;
526        static _dispatch_main_q: c_void;
527        fn dispatch_async_f(
528            queue: *const c_void,
529            context: *mut c_void,
530            work: unsafe extern "C" fn(*mut c_void),
531        );
532    }
533
534    pub fn available() -> bool {
535        unsafe { gpui_sf_symbol_available() }
536    }
537
538    pub fn exists(name: &str) -> bool {
539        let Ok(name) = CString::new(name) else {
540            return false;
541        };
542        unsafe { gpui_sf_symbol_exists(name.as_ptr()) }
543    }
544
545    pub fn render(symbol: SfSymbol) -> Option<Arc<[u8]>> {
546        if unsafe { pthread_main_np() } != 0 {
547            return render_on_main(symbol);
548        }
549
550        // Hop to the main queue, which GPUI's run loop drains, and wait. The
551        // job owns its context, so giving up early leaves nothing dangling.
552        type Job = (SfSymbol, mpsc::Sender<Option<Arc<[u8]>>>);
553        unsafe extern "C" fn run(context: *mut c_void) {
554            let (symbol, done) = *unsafe { Box::from_raw(context.cast::<Job>()) };
555            done.send(render_on_main(symbol)).ok();
556        }
557        let (done, result) = mpsc::channel();
558        let job: Box<Job> = Box::new((symbol, done));
559        unsafe {
560            dispatch_async_f(&raw const _dispatch_main_q, Box::into_raw(job).cast(), run);
561        }
562        result.recv_timeout(MAIN_THREAD_TIMEOUT).ok().flatten()
563    }
564
565    fn render_on_main(symbol: SfSymbol) -> Option<Arc<[u8]>> {
566        let name = CString::new(symbol.name()).ok()?;
567        let variable_value = symbol.get_variable_value().map_or(f64::NAN, f64::from);
568        let mut length = 0;
569        unsafe {
570            let buffer = gpui_sf_symbol_svg(
571                name.as_ptr(),
572                // Same order as the shim's `weights` table.
573                symbol.weight as i32,
574                symbol.rendering == Rendering::Hierarchical,
575                variable_value,
576                &mut length,
577            );
578            if buffer.is_null() {
579                return None;
580            }
581            let svg = Arc::from(std::slice::from_raw_parts(buffer, length));
582            gpui_sf_symbol_free(buffer);
583            Some(svg)
584        }
585    }
586}
587
588#[cfg(not(sf_symbols_shim))]
589mod shim {
590    use std::sync::Arc;
591
592    use crate::SfSymbol;
593
594    pub fn available() -> bool {
595        false
596    }
597
598    pub fn exists(_name: &str) -> bool {
599        false
600    }
601
602    pub fn render(_symbol: SfSymbol) -> Option<Arc<[u8]>> {
603        None
604    }
605}