forked from reya/gpui-native-kit
324 lines
10 KiB
Rust
324 lines
10 KiB
Rust
//! 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<Hsla>,
|
|
}
|
|
|
|
/// 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<SharedString> {
|
|
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<std::io::Result<Vec<u8>>> {
|
|
let rest = path.strip_prefix("icons/freedesktop/")?;
|
|
let stem = rest.strip_suffix(".svg")?;
|
|
let (slug, size) = stem.rsplit_once('-')?;
|
|
let size = size.parse::<u32>().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<Option<std::borrow::Cow<'static, [u8]>>> {
|
|
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<Vec<SharedString>> {
|
|
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");
|
|
}
|
|
}
|