//! System icons for GPUI: components and apps reference semantic roles, //! and the crate resolves each role to a glyph for the current platform. use gpui::prelude::FluentBuilder as _; use gpui::{ AssetSource, Hsla, IntoElement, ParentElement as _, Pixels, RenderOnce, SharedString, Styled as _, Window, div, px, svg, }; #[cfg_attr(not(target_os = "linux"), allow(dead_code))] mod freedesktop; #[cfg_attr(not(target_os = "windows"), allow(dead_code))] mod segoe; /// The semantic glyphs UI components need. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum IconRole { Check, Close, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Plus, Minus, Search, Folder, Warning, Info, Eye, EyeOff, } impl IconRole { pub fn all() -> &'static [Self] { &[ Self::Check, Self::Close, Self::ChevronDown, Self::ChevronLeft, Self::ChevronRight, Self::ChevronUp, Self::Plus, Self::Minus, Self::Search, Self::Folder, Self::Warning, Self::Info, Self::Eye, Self::EyeOff, ] } fn asset_path(self) -> &'static str { match self { Self::Check => "icons/check.svg", Self::Close => "icons/x.svg", Self::ChevronDown => "icons/chevron-down.svg", Self::ChevronLeft => "icons/chevron-left.svg", Self::ChevronRight => "icons/chevron-right.svg", Self::ChevronUp => "icons/chevron-up.svg", Self::Plus => "icons/plus.svg", Self::Minus => "icons/minus.svg", Self::Search => "icons/search.svg", Self::Folder => "icons/folder.svg", Self::Warning => "icons/triangle-alert.svg", Self::Info => "icons/info.svg", Self::Eye => "icons/eye.svg", Self::EyeOff => "icons/eye-off.svg", } } fn slug(self) -> &'static str { match self { Self::Check => "check", Self::Close => "close", Self::ChevronDown => "chevron-down", Self::ChevronLeft => "chevron-left", Self::ChevronRight => "chevron-right", Self::ChevronUp => "chevron-up", Self::Plus => "plus", Self::Minus => "minus", Self::Search => "search", Self::Folder => "folder", Self::Warning => "warning", Self::Info => "info", Self::Eye => "eye", Self::EyeOff => "eye-off", } } /// Freedesktop names the role resolves through, best first. /// /// Symbolic names lead (Adwaita), with Breeze and legacy names as fallbacks. fn freedesktop_names(self) -> &'static [&'static str] { match self { Self::Check => &[ "object-select-symbolic", "object-select", "emblem-ok-symbolic", "checkmark", ], Self::Close => &[ "window-close-symbolic", "window-close", "close-symbolic", "gtk-close", ], Self::ChevronDown => &[ "pan-down-symbolic", "pan-down", "go-down-symbolic", "arrow-down", ], Self::ChevronLeft => &[ "pan-start-symbolic", "pan-start", "go-previous-symbolic", "arrow-left", ], Self::ChevronRight => &[ "pan-end-symbolic", "pan-end", "go-next-symbolic", "arrow-right", ], Self::ChevronUp => &["pan-up-symbolic", "pan-up", "go-up-symbolic", "arrow-up"], Self::Plus => &["list-add-symbolic", "list-add", "add"], Self::Minus => &["list-remove-symbolic", "list-remove", "remove"], Self::Search => &[ "system-search-symbolic", "system-search", "search", "edit-find", ], Self::Folder => &["folder-symbolic", "folder"], Self::Warning => &["dialog-warning-symbolic", "dialog-warning", "warning"], Self::Info => &["dialog-information-symbolic", "dialog-information", "info"], Self::Eye => &["view-visible-symbolic", "view-visible", "visibility", "eye"], Self::EyeOff => &[ "view-conceal-symbolic", "view-hide-symbolic", "view-hide", "visibility-off", "eye-off", ], } } } /// An icon element. Unpainted unless a color is set. #[derive(IntoElement)] pub struct Icon { role: IconRole, size: Pixels, color: Option, } /// Creates an icon for the role at the default size of 16px. pub fn icon(role: IconRole) -> Icon { Icon { role, size: px(16.0), color: None, } } impl Icon { pub fn size(mut self, size: Pixels) -> Self { self.size = size; self } pub fn color(mut self, color: Hsla) -> Self { self.color = Some(color); self } /// 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())) }) } } 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. #[cfg(target_os = "windows")] fn render_glyph(self) -> impl IntoElement { div() .flex_none() .size(self.size) .flex() .items_center() .justify_center() .when_some(segoe::glyph(self.role), |el, glyph| { el.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()), ) }) } #[cfg(not(target_os = "windows"))] 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)), ) } } /// The asset source serving this crate's bundled glyphs. pub struct IconAssets; fn asset_bytes(path: &str) -> Option<&'static [u8]> { match path { "icons/check.svg" => Some(include_bytes!("../assets/check.svg")), "icons/x.svg" => Some(include_bytes!("../assets/x.svg")), "icons/chevron-down.svg" => Some(include_bytes!("../assets/chevron-down.svg")), "icons/chevron-left.svg" => Some(include_bytes!("../assets/chevron-left.svg")), "icons/chevron-right.svg" => Some(include_bytes!("../assets/chevron-right.svg")), "icons/chevron-up.svg" => Some(include_bytes!("../assets/chevron-up.svg")), "icons/plus.svg" => Some(include_bytes!("../assets/plus.svg")), "icons/minus.svg" => Some(include_bytes!("../assets/minus.svg")), "icons/search.svg" => Some(include_bytes!("../assets/search.svg")), "icons/folder.svg" => Some(include_bytes!("../assets/folder.svg")), "icons/triangle-alert.svg" => Some(include_bytes!("../assets/triangle-alert.svg")), "icons/info.svg" => Some(include_bytes!("../assets/info.svg")), "icons/eye.svg" => Some(include_bytes!("../assets/eye.svg")), "icons/eye-off.svg" => Some(include_bytes!("../assets/eye-off.svg")), _ => None, } } fn freedesktop_file(path: &str) -> Option>> { let rest = path.strip_prefix("icons/freedesktop/")?; let stem = rest.strip_suffix(".svg")?; let (slug, size) = stem.rsplit_once('-')?; let size = size.parse::().ok()?; let role = IconRole::all().iter().find(|role| role.slug() == slug)?; freedesktop::cached_file(*role, size).map(std::fs::read) } impl AssetSource for IconAssets { fn load(&self, path: &str) -> gpui::Result>> { if let Some(file) = freedesktop_file(path) { let bytes = file.map_err(|error| { anyhow::anyhow!("failed to read freedesktop icon {path}: {error}") })?; return Ok(Some(std::borrow::Cow::Owned(bytes))); } Ok(asset_bytes(path).map(std::borrow::Cow::Borrowed)) } fn list(&self, path: &str) -> gpui::Result> { if path.is_empty() { Ok(IconRole::all() .iter() .map(|role| SharedString::from(role.asset_path())) .collect()) } else { Ok(vec![]) } } } #[cfg(test)] mod tests { use super::*; #[test] fn every_role_resolves_to_a_bundled_asset() { for role in IconRole::all() { assert!( asset_bytes(role.asset_path()).is_some(), "missing bundled asset for {role:?}" ); } } #[test] fn freedesktop_asset_keys_round_trip() { let role = IconRole::ChevronDown; let key = format!("icons/freedesktop/{}-16.svg", role.slug()); let stem = key .strip_prefix("icons/freedesktop/") .unwrap() .strip_suffix(".svg") .unwrap(); let (slug, parsed_size) = stem.rsplit_once('-').unwrap(); let resolved = IconRole::all() .iter() .find(|candidate| candidate.slug() == slug); assert_eq!(resolved, Some(&role)); assert_eq!(parsed_size, "16"); } }