use crate::IconName;
use base64::{Engine as _, engine::general_purpose::STANDARD};
use gpui_kit::{FontWeight, Hsla, RenderImage, Rgba};
use image::{Frame, ImageEncoder as _, RgbaImage, codecs::png::PngEncoder};
use objc2::{AnyThread, rc::autoreleasepool};
use objc2_app_kit::{
NSBitmapFormat, NSBitmapImageRep, NSCompositingOperation, NSDeviceRGBColorSpace,
NSGraphicsContext, NSImage, NSImageSymbolConfiguration, NSImageSymbolScale,
};
use objc2_foundation::{NSPoint, NSRect, NSSize, NSString};
use std::{
cell::RefCell,
collections::HashMap,
sync::{Arc, LazyLock},
};
#[derive(Clone, Hash, PartialEq, Eq)]
struct MaskKey {
symbol: String,
points: u16,
pixels: u16,
weight: u8,
}
#[derive(Clone, Hash, PartialEq, Eq)]
struct ImageKey {
mask: MaskKey,
color: [u8; 4],
}
struct AlphaMask {
pixels: u16,
alpha: Vec<u8>,
}
#[derive(Default)]
struct SymbolCache {
names: HashMap<IconName, Option<String>>,
masks: HashMap<MaskKey, Arc<AlphaMask>>,
images: HashMap<ImageKey, Arc<RenderImage>>,
svg: HashMap<IconName, Option<Arc<[u8]>>>,
}
static PATH_NAMES: LazyLock<HashMap<gpui_kit::SharedString, IconName>> = LazyLock::new(|| {
IconName::ALL
.iter()
.copied()
.map(|name| (name.path(), name))
.collect()
});
thread_local! {
static CACHE: RefCell<SymbolCache> = RefCell::new(SymbolCache::default());
}
pub(crate) fn svg_for_path(path: &str) -> Option<Arc<[u8]>> {
let name = *PATH_NAMES.get(path)?;
if let Some(cached) = CACHE.with_borrow(|cache| cache.svg.get(&name).cloned()) {
return cached;
}
let svg = (|| {
let image = image(
name,
16.,
8.,
FontWeight::NORMAL,
gpui_kit::rgb(0xffffff).into(),
)?;
let mut png = Vec::new();
PngEncoder::new(&mut png)
.write_image(
image.as_bytes(0)?,
128,
128,
image::ExtendedColorType::Rgba8,
)
.ok()?;
let png = STANDARD.encode(png);
Some(Arc::from(format!(r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="128" height="128" viewBox="0 0 128 128"><image width="128" height="128" xlink:href="data:image/png;base64,{png}"/></svg>"#).into_bytes()))
})();
CACHE.with_borrow_mut(|cache| {
cache.svg.insert(name, svg.clone());
});
svg
}
pub(crate) fn image(
name: IconName,
points: f32,
scale: f32,
weight: FontWeight,
color: Hsla,
) -> Option<Arc<RenderImage>> {
if !points.is_finite() || points <= 0. || !scale.is_finite() || scale <= 0. {
return None;
}
let points = (points.clamp(1., 256.) * 64.).round() as u16;
let pixels = ((points as f32 / 64. * scale).ceil() as u16).clamp(1, 1024);
let weight = (weight.0 / 100.).round().clamp(1., 9.) as u8;
let rgba: Rgba = color.into();
let color = [rgba.r, rgba.g, rgba.b, rgba.a].map(|c| (c.clamp(0., 1.) * 255.).round() as u8);
CACHE.with_borrow_mut(|cache| {
let symbol = cache
.names
.entry(name)
.or_insert_with(|| resolve_name(name))
.clone()?;
let mask_key = MaskKey {
symbol,
points,
pixels,
weight,
};
let image_key = ImageKey {
mask: mask_key.clone(),
color,
};
if let Some(image) = cache.images.get(&image_key) {
return Some(image.clone());
}
let mask = match cache.masks.get(&mask_key) {
Some(mask) => mask.clone(),
None => {
let mask = Arc::new(rasterize(&mask_key)?);
if cache.masks.len() >= 256 {
cache.masks.clear();
}
cache.masks.insert(mask_key, mask.clone());
mask
}
};
let bytes = tint(&mask.alpha, color);
let buffer = RgbaImage::from_raw(mask.pixels.into(), mask.pixels.into(), bytes)?;
let image = Arc::new(RenderImage::new(vec![Frame::new(buffer)]));
if cache.images.len() >= 512 {
cache.images.clear();
}
cache.images.insert(image_key, image.clone());
Some(image)
})
}
fn tint(alpha: &[u8], color: [u8; 4]) -> Vec<u8> {
alpha
.iter()
.flat_map(|coverage| {
[
color[2],
color[1],
color[0],
((*coverage as u16 * color[3] as u16 + 127) / 255) as u8,
]
})
.collect()
}
fn resolve_name(name: IconName) -> Option<String> {
autoreleasepool(|_| {
let preferred = preferred_symbol(name);
let path = name.path();
let conventional = path
.rsplit('/')
.next()?
.strip_suffix(".svg")?
.replace('-', ".");
for candidate in preferred
.into_iter()
.chain(std::iter::once(conventional.as_str()))
{
if NSImage::imageWithSystemSymbolName_accessibilityDescription(
&NSString::from_str(candidate),
None,
)
.is_some()
{
return Some(candidate.to_owned());
}
}
None
})
}
fn rasterize(key: &MaskKey) -> Option<AlphaMask> {
autoreleasepool(|_| {
let symbol = NSImage::imageWithSystemSymbolName_accessibilityDescription(
&NSString::from_str(&key.symbol),
None,
)?;
let weight = [-0.8, -0.6, -0.4, 0., 0.23, 0.3, 0.4, 0.56, 0.62][key.weight as usize - 1];
let config = NSImageSymbolConfiguration::configurationWithPointSize_weight_scale(
key.points as f64 / 64.,
weight,
NSImageSymbolScale::Medium,
)
.configurationByApplyingConfiguration(
&NSImageSymbolConfiguration::configurationPreferringMonochrome(),
);
let symbol = symbol.imageWithSymbolConfiguration(&config)?;
let pixels = usize::from(key.pixels);
let bitmap = unsafe {
NSBitmapImageRep::initWithBitmapDataPlanes_pixelsWide_pixelsHigh_bitsPerSample_samplesPerPixel_hasAlpha_isPlanar_colorSpaceName_bitmapFormat_bytesPerRow_bitsPerPixel(
NSBitmapImageRep::alloc(), std::ptr::null_mut(), pixels as isize, pixels as isize,
8, 4, true, false, NSDeviceRGBColorSpace, NSBitmapFormat::empty(),
(pixels * 4) as isize, 32,
)
}?;
let data = bitmap.bitmapData();
if data.is_null() {
return None;
}
unsafe { std::ptr::write_bytes(data, 0, pixels * pixels * 4) };
let context = NSGraphicsContext::graphicsContextWithBitmapImageRep(&bitmap)?;
let intrinsic = symbol.size();
if intrinsic.width <= 0. || intrinsic.height <= 0. {
return None;
}
let fit = pixels as f64 / intrinsic.width.max(intrinsic.height);
let size = NSSize::new(intrinsic.width * fit, intrinsic.height * fit);
let rect = NSRect::new(
NSPoint::new(
(pixels as f64 - size.width) / 2.,
(pixels as f64 - size.height) / 2.,
),
size,
);
NSGraphicsContext::saveGraphicsState_class();
NSGraphicsContext::setCurrentContext(Some(&context));
symbol.drawInRect_fromRect_operation_fraction(
rect,
NSRect::new(NSPoint::new(0., 0.), NSSize::new(0., 0.)),
NSCompositingOperation::SourceOver,
1.,
);
NSGraphicsContext::restoreGraphicsState_class();
let bytes = unsafe { std::slice::from_raw_parts(data, pixels * pixels * 4) };
let alpha = bytes
.as_chunks::<4>()
.0
.iter()
.map(|pixel| pixel[3])
.collect::<Vec<_>>();
if !alpha.iter().any(|alpha| *alpha > 0) {
return None;
}
Some(AlphaMask {
pixels: key.pixels,
alpha,
})
})
}
fn preferred_symbol(name: IconName) -> Option<&'static str> {
Some(match name {
IconName::Check => "checkmark",
IconName::Minus => "minus",
IconName::Plus => "plus",
IconName::ChevronDown => "chevron.down",
IconName::ChevronUp => "chevron.up",
IconName::ChevronLeft => "chevron.left",
IconName::ChevronRight => "chevron.right",
IconName::ChevronsUpDown => "chevron.up.chevron.down",
IconName::Calendar => "calendar",
IconName::Clock => "clock",
IconName::Star => "star",
IconName::ExternalLink => "arrow.up.right.square",
IconName::Close => "xmark",
IconName::Search => "magnifyingglass",
IconName::Menu => "line.3.horizontal",
IconName::Settings => "gearshape",
IconName::SlidersHorizontal => "slider.horizontal.3",
IconName::TriangleAlert => "exclamationmark.triangle",
IconName::CircleCheck => "checkmark.circle",
IconName::AppWindow => "macwindow",
IconName::ChartLine => "chart.xyaxis.line",
IconName::Command => "command",
IconName::File => "doc",
IconName::FileText => "doc.text",
IconName::Folder => "folder",
IconName::Layers => "square.3.layers.3d",
IconName::MessageSquare => "bubble.left",
IconName::PanelLeft => "sidebar.left",
IconName::PanelsTopLeft => "rectangle.split.2x2",
IconName::TextCursorInput => "textbox",
IconName::Users => "person.2",
_ => return translated_symbol(name.path().as_ref()),
})
}
fn translated_symbol(path: &str) -> Option<&'static str> {
let stem = path.rsplit('/').next()?.strip_suffix(".svg")?;
Some(match stem {
"arrow-down-left" => "arrow.down.left",
"arrow-down-right" => "arrow.down.right",
"arrow-up-left" => "arrow.up.left",
"arrow-up-right" => "arrow.up.right",
"arrow-left-right" => "arrow.left.arrow.right",
"arrow-up-down" => "arrow.up.arrow.down",
"arrow-big-down" => "arrow.down",
"arrow-big-left" => "arrow.left",
"arrow-big-right" => "arrow.right",
"arrow-big-up" => "arrow.up",
"arrow-down-to-line" | "download" => "arrow.down.to.line",
"arrow-up-from-line" | "upload" => "arrow.up.to.line",
"arrow-down-to-dot" => "arrow.down.to.line.compact",
"arrow-up-to-line" => "arrow.up.to.line",
"chevrons-down" => "chevron.down.2",
"chevrons-left" => "chevron.left.2",
"chevrons-right" => "chevron.right.2",
"chevrons-up" => "chevron.up.2",
"corner-down-left" => "arrow.turn.down.left",
"corner-down-right" => "arrow.turn.down.right",
"corner-up-left" => "arrow.turn.up.left",
"corner-up-right" => "arrow.turn.up.right",
"move" => "arrow.up.and.down.and.arrow.left.and.right",
"move-horizontal" => "arrow.left.and.right",
"move-vertical" => "arrow.up.and.down",
"maximize" | "maximize-2" => "arrow.up.left.and.arrow.down.right",
"minimize" | "minimize-2" => "arrow.down.right.and.arrow.up.left",
"refresh-cw" | "rotate-cw" => "arrow.clockwise",
"refresh-ccw" | "rotate-ccw" => "arrow.counterclockwise",
"undo" | "undo-2" => "arrow.uturn.backward",
"redo" | "redo-2" => "arrow.uturn.forward",
"play" => "play.fill",
"pause" => "pause.fill",
"stop" | "square" => "stop",
"skip-back" => "backward.end.fill",
"skip-forward" => "forward.end.fill",
"fast-forward" => "forward.fill",
"rewind" => "backward.fill",
"volume" | "volume-1" => "speaker.wave.1",
"volume-2" => "speaker.wave.2",
"volume-x" => "speaker.slash",
"mic" => "mic",
"mic-off" => "mic.slash",
"video" => "video",
"video-off" => "video.slash",
"circle-check" | "circle-check-2" => "checkmark.circle",
"circle-x" => "xmark.circle",
"circle-plus" => "plus.circle",
"circle-minus" => "minus.circle",
"circle-alert" => "exclamationmark.circle",
"circle-help" => "questionmark.circle",
"square-check" | "square-check-big" => "checkmark.square",
"square-x" => "xmark.square",
"square-plus" => "plus.square",
"square-minus" => "minus.square",
"info" => "info.circle",
"eye-off" => "eye.slash",
"lock" | "lock-keyhole" => "lock",
"lock-open" | "lock-keyhole-open" => "lock.open",
"shield-check" => "checkmark.shield",
"shield-alert" => "exclamationmark.shield",
"user" | "user-round" => "person",
"users" | "users-round" => "person.2",
"user-plus" | "user-round-plus" => "person.badge.plus",
"user-minus" | "user-round-minus" => "person.badge.minus",
"user-check" | "user-round-check" => "person.crop.circle.badge.checkmark",
"user-x" | "user-round-x" => "person.crop.circle.badge.xmark",
"circle-user" | "circle-user-round" => "person.crop.circle",
"contact" => "person.crop.square",
"log-in" => "rectangle.portrait.and.arrow.right",
"log-out" => "rectangle.portrait.and.arrow.forward",
"home" | "house" => "house",
"building" | "building-2" => "building.2",
"file" => "doc",
"file-text" => "doc.text",
"file-code" | "file-code-2" => "doc.text",
"file-plus" | "file-plus-2" => "doc.badge.plus",
"file-check" | "file-check-2" => "checkmark.rectangle",
"file-x" | "file-x-2" => "doc.badge.xmark",
"files" | "copy" => "doc.on.doc",
"folder-open" => "folder",
"folder-plus" => "folder.badge.plus",
"folder-minus" => "folder.badge.minus",
"archive" => "archivebox",
"clipboard" | "clipboard-copy" => "doc.on.clipboard",
"clipboard-check" => "checklist",
"clipboard-list" => "list.clipboard",
"save" => "square.and.arrow.down",
"share" | "share-2" => "square.and.arrow.up",
"trash" | "trash-2" => "trash",
"pencil" | "pencil-line" => "pencil",
"square-pen" | "notebook-pen" => "square.and.pencil",
"image" | "image-plus" => "photo",
"images" => "photo.on.rectangle",
"mail" => "envelope",
"mail-open" => "envelope.open",
"mail-plus" => "envelope.badge",
"send" => "paperplane",
"message-square" => "bubble.left",
"message-circle" => "bubble.left",
"messages-square" => "bubble.left.and.bubble.right",
"bell-off" => "bell.slash",
"bell-ring" => "bell.badge",
"ellipsis" | "more-horizontal" => "ellipsis",
"ellipsis-vertical" | "more-vertical" => "ellipsis",
"list" | "list-collapse" => "list.bullet",
"list-ordered" => "list.number",
"list-checks" | "list-todo" => "checklist",
"align-left" => "text.alignleft",
"align-center" => "text.aligncenter",
"align-right" => "text.alignright",
"align-justify" => "text.justify",
"bold" => "bold",
"italic" => "italic",
"underline" => "underline",
"strikethrough" => "strikethrough",
"quote" => "quote.opening",
"code" | "code-2" => "chevron.left.forwardslash.chevron.right",
"terminal" | "square-terminal" => "terminal",
"link" | "link-2" => "link",
"panel-left" | "panel-left-close" | "panel-left-open" => "sidebar.left",
"panel-right" | "panel-right-close" | "panel-right-open" => "sidebar.right",
"panel-top" => "rectangle.topthird.inset.filled",
"panel-bottom" | "panel-bottom-open" => "rectangle.bottomthird.inset.filled",
"panels-top-left" | "layout-dashboard" => "rectangle.split.2x2",
"columns-2" => "rectangle.split.2x1",
"rows-2" => "rectangle.split.1x2",
"grid-2x2" | "grid-3x3" | "layout-grid" => "square.grid.2x2",
"chart-bar" | "chart-column" | "chart-no-axes-column" => "chart.bar",
"chart-line" | "chart-area" => "chart.xyaxis.line",
"chart-pie" => "chart.pie",
"filter" => "line.3.horizontal.decrease",
"sliders-horizontal" => "slider.horizontal.3",
"settings-2" => "slider.horizontal.3",
"search" => "magnifyingglass",
"zoom-in" => "plus.magnifyingglass",
"zoom-out" => "minus.magnifyingglass",
"cloud-download" => "icloud.and.arrow.down",
"cloud-upload" => "icloud.and.arrow.up",
"cloud-off" => "icloud.slash",
"map-pin" => "mappin",
"navigation" => "location",
"compass" => "safari",
"monitor" => "display",
"laptop" => "laptopcomputer",
"smartphone" => "iphone",
"tablet" => "ipad",
"database" => "externaldrive",
"hard-drive" => "internaldrive",
"plug" => "powerplug",
"battery" | "battery-full" => "battery.100percent",
"battery-medium" => "battery.50percent",
"battery-low" => "battery.25percent",
"battery-charging" => "battery.100percent.bolt",
"wifi-off" => "wifi.slash",
"sun" => "sun.max",
"moon" => "moon",
"snowflake" => "snowflake",
"flame" => "flame",
"zap" => "bolt",
"zap-off" => "bolt.slash",
"sparkles" => "sparkles",
"asterisk" => "asterisk",
"braces" => "curlybraces",
"book-open" => "book",
"box" => "cube",
"brain" => "brain.head.profile",
"case-sensitive" => "textformat",
"a-large-small" => "textformat.size",
"ban" => "nosign",
"dash" => "minus",
"frame" => "viewfinder",
"inbox" => "tray",
"chart-no-axes-combined" => "chart.xyaxis.line",
"check-check" => "checkmark.2",
"circle-chevron-down" => "chevron.down.circle",
"circle-chevron-left" => "chevron.left.circle",
"circle-chevron-right" => "chevron.right.circle",
"circle-chevron-up" => "chevron.up.circle",
"construction" => "hammer",
"delete" => "delete.left",
"dot" => "circle.fill",
"earth" => "globe.europe.africa",
"file-search" | "file-search-2" => "doc.text.magnifyingglass",
"folder-closed" => "folder",
"gallery-vertical-end" => "square.stack",
"heart-off" => "heart.slash",
"inspector" => "sidebar.right",
"loader" | "loader-circle" => "progress.indicator",
"memory-stick" => "memorychip",
"palette" => "paintpalette",
"replace" => "arrow.triangle.2.circlepath",
"resize-corner" => "arrow.up.left.and.arrow.down.right",
"sort-ascending" | "arrow-down-wide-narrow" => "arrow.up",
"sort-descending" | "arrow-up-wide-narrow" => "arrow.down",
"star-fill" => "star.fill",
"star-off" => "star.slash",
"thumbs-up" => "hand.thumbsup",
"thumbs-down" => "hand.thumbsdown",
"window-close" | "x" => "xmark",
"window-maximize" => "arrow.up.left.and.arrow.down.right",
"window-minimize" => "minus",
"window-restore" => "arrow.down.right.and.arrow.up.left",
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tint_preserves_antialiasing_and_inherited_alpha_in_bgra() {
assert_eq!(
tint(&[0, 128, 255], [20, 40, 80, 128]),
[80, 40, 20, 0, 80, 40, 20, 64, 80, 40, 20, 128]
);
}
#[test]
fn the_host_renders_every_symbol_used_by_native_controls_and_gallery() {
for name in [
IconName::AppWindow,
IconName::Calendar,
IconName::ChartLine,
IconName::Check,
IconName::ChevronDown,
IconName::ChevronLeft,
IconName::ChevronRight,
IconName::ChevronUp,
IconName::ChevronsUpDown,
IconName::CircleCheck,
IconName::Clock,
IconName::Close,
IconName::Command,
IconName::ExternalLink,
IconName::File,
IconName::FileText,
IconName::Folder,
IconName::Layers,
IconName::Menu,
IconName::MessageSquare,
IconName::Minus,
IconName::PanelLeft,
IconName::PanelsTopLeft,
IconName::Plus,
IconName::Search,
IconName::Settings,
IconName::SlidersHorizontal,
IconName::Star,
IconName::TextCursorInput,
IconName::TriangleAlert,
IconName::Users,
] {
let rendered = image(
name,
16.,
2.,
FontWeight::NORMAL,
gpui_kit::rgb(0x007aff).into(),
)
.unwrap_or_else(|| {
panic!(
"the current host should supply {name:?}: {:?}",
preferred_symbol(name)
)
});
let bytes = rendered.as_bytes(0).unwrap();
assert_eq!(bytes.len(), 32 * 32 * 4);
assert!(
bytes.as_chunks::<4>().0.iter().any(|pixel| pixel[3] > 0),
"{name:?}"
);
assert!(
bytes.as_chunks::<4>().0.iter().any(|pixel| pixel[3] == 0),
"{name:?}: preserves transparency"
);
let cached = image(
name,
16.,
2.,
FontWeight::NORMAL,
gpui_kit::rgb(0x007aff).into(),
)
.unwrap();
assert!(
Arc::ptr_eq(&rendered, &cached),
"{name:?}: reuses atlas identity"
);
}
}
#[test]
fn catalog_coverage_counts_native_symbols_and_fallbacks_on_this_host() {
use gpui_kit::AssetSource as _;
let total = IconName::ALL.len();
let native = IconName::ALL
.iter()
.copied()
.filter(|name| {
image(
*name,
16.,
1.,
FontWeight::NORMAL,
gpui_kit::rgb(0xffffff).into(),
)
.is_some()
})
.count();
let cached = CACHE.with_borrow(|cache| cache.names.len());
assert_eq!(
cached, total,
"both successful and failed lookups are cached"
);
assert!(
native >= 31,
"native controls are covered on the supported host"
);
println!(
"SF Symbol catalog coverage on this host: {native}/{total} native; {} Lucide fallbacks; {cached} cached lookups",
total - native
);
let defaults = gpui_kit::assets::Assets.list("icons/").unwrap();
let default_fallbacks = CACHE.with_borrow(|cache| {
defaults
.iter()
.filter(|path| {
PATH_NAMES
.get(path.as_str())
.is_none_or(|name| cache.names.get(name).is_none_or(Option::is_none))
})
.collect::<Vec<_>>()
});
println!(
"GPUI Kit default bundle: {}/{} native; {} fallbacks: {default_fallbacks:?}",
defaults.len() - default_fallbacks.len(),
defaults.len(),
default_fallbacks.len()
);
}
}