use std::cell::RefCell;
use std::collections::HashMap;
use skia_safe::FontStyle;
use crate::render::fonts::{FontRegistry, TypefaceEntry};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum EmojiFamily {
SegoeUiEmoji,
AppleColorEmoji,
NotoColorEmoji,
TwitterColorEmoji,
JoyPixels,
EmojiOneColor,
}
impl EmojiFamily {
pub const fn family_name(self) -> &'static str {
match self {
EmojiFamily::SegoeUiEmoji => "Segoe UI Emoji",
EmojiFamily::AppleColorEmoji => "Apple Color Emoji",
EmojiFamily::NotoColorEmoji => "Noto Color Emoji",
EmojiFamily::TwitterColorEmoji => "Twitter Color Emoji",
EmojiFamily::JoyPixels => "JoyPixels",
EmojiFamily::EmojiOneColor => "EmojiOne Color",
}
}
pub const fn host_default() -> &'static [EmojiFamily] {
HOST_DEFAULT
}
pub fn from_name_ci(name: &str) -> Option<EmojiFamily> {
match name.to_ascii_lowercase().as_str() {
"segoe ui emoji" => Some(EmojiFamily::SegoeUiEmoji),
"apple color emoji" => Some(EmojiFamily::AppleColorEmoji),
"noto color emoji" => Some(EmojiFamily::NotoColorEmoji),
"twitter color emoji" => Some(EmojiFamily::TwitterColorEmoji),
"joypixels" => Some(EmojiFamily::JoyPixels),
"emojione color" => Some(EmojiFamily::EmojiOneColor),
_ => None,
}
}
}
#[cfg(target_os = "macos")]
const HOST_DEFAULT: &[EmojiFamily] = &[EmojiFamily::AppleColorEmoji];
#[cfg(target_os = "windows")]
const HOST_DEFAULT: &[EmojiFamily] = &[EmojiFamily::SegoeUiEmoji];
#[cfg(target_os = "linux")]
const HOST_DEFAULT: &[EmojiFamily] = &[
EmojiFamily::NotoColorEmoji,
EmojiFamily::TwitterColorEmoji,
EmojiFamily::JoyPixels,
EmojiFamily::EmojiOneColor,
];
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
const HOST_DEFAULT: &[EmojiFamily] = &[];
#[derive(Clone, Debug)]
pub enum EmojiTypeface {
Resolved {
family: EmojiFamily,
entry: TypefaceEntry,
},
Unavailable {
attempted: Vec<EmojiFamily>,
},
}
pub trait EmojiTypefaceLookup {
fn lookup(&self, family: EmojiFamily) -> Option<TypefaceEntry>;
}
pub struct RegistryLookup<'a> {
pub registry: &'a FontRegistry,
}
impl EmojiTypefaceLookup for RegistryLookup<'_> {
fn lookup(&self, family: EmojiFamily) -> Option<TypefaceEntry> {
self.registry
.resolve_system_only(family.family_name(), FontStyle::normal())
}
}
pub fn resolve(lookup: &impl EmojiTypefaceLookup, requested: Option<EmojiFamily>) -> EmojiTypeface {
let mut attempted: Vec<EmojiFamily> = Vec::new();
if let Some(family) = requested {
attempted.push(family);
if let Some(entry) = lookup.lookup(family) {
return EmojiTypeface::Resolved { family, entry };
}
}
for &family in EmojiFamily::host_default() {
if attempted.contains(&family) {
continue;
}
attempted.push(family);
if let Some(entry) = lookup.lookup(family) {
return EmojiTypeface::Resolved { family, entry };
}
}
EmojiTypeface::Unavailable { attempted }
}
pub struct EmojiResolver<L: EmojiTypefaceLookup> {
lookup: L,
cache: RefCell<HashMap<Option<EmojiFamily>, EmojiTypeface>>,
}
impl<L: EmojiTypefaceLookup> EmojiResolver<L> {
pub fn new(lookup: L) -> Self {
Self {
lookup,
cache: RefCell::new(HashMap::new()),
}
}
pub fn resolve(&self, requested: Option<EmojiFamily>) -> EmojiTypeface {
if let Some(cached) = self.cache.borrow().get(&requested) {
return cached.clone();
}
let result = resolve(&self.lookup, requested);
self.cache.borrow_mut().insert(requested, result.clone());
result
}
pub fn cached_count(&self) -> usize {
self.cache.borrow().len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::render::fonts::{TypefaceId, TypefaceOrigin};
use skia_safe::FontMgr;
use std::cell::Cell;
fn any_typeface_entry() -> TypefaceEntry {
let mgr = FontMgr::new();
let tf = mgr
.legacy_make_typeface(None::<&str>, FontStyle::normal())
.expect("system has no default typeface — cannot run test");
let id = TypefaceId::from(&tf);
TypefaceEntry {
typeface: tf,
origin: TypefaceOrigin::System { typeface_id: id },
}
}
struct MockLookup {
available: Vec<EmojiFamily>,
calls: Cell<usize>,
}
impl MockLookup {
fn new(available: Vec<EmojiFamily>) -> Self {
Self {
available,
calls: Cell::new(0),
}
}
}
impl EmojiTypefaceLookup for MockLookup {
fn lookup(&self, family: EmojiFamily) -> Option<TypefaceEntry> {
self.calls.set(self.calls.get() + 1);
if self.available.contains(&family) {
Some(any_typeface_entry())
} else {
None
}
}
}
#[cfg(target_os = "macos")]
#[test]
fn r1_macos_default_resolves_to_apple_color_emoji() {
let registry = FontRegistry::new(FontMgr::new());
let lookup = RegistryLookup {
registry: ®istry,
};
let result = resolve(&lookup, None);
assert!(
matches!(
result,
EmojiTypeface::Resolved {
family: EmojiFamily::AppleColorEmoji,
..
}
),
"expected Resolved(AppleColorEmoji), got {result:?}"
);
}
#[cfg(target_os = "macos")]
#[test]
fn r2_macos_falls_back_when_requested_missing() {
let registry = FontRegistry::new(FontMgr::new());
let lookup = RegistryLookup {
registry: ®istry,
};
let result = resolve(&lookup, Some(EmojiFamily::SegoeUiEmoji));
assert!(
matches!(
result,
EmojiTypeface::Resolved {
family: EmojiFamily::AppleColorEmoji,
..
}
),
"expected fallback to AppleColorEmoji, got {result:?}"
);
}
#[cfg(target_os = "linux")]
#[test]
fn r3_linux_noto_when_installed() {
let registry = FontRegistry::new(FontMgr::new());
let lookup = RegistryLookup {
registry: ®istry,
};
if lookup.lookup(EmojiFamily::NotoColorEmoji).is_none() {
eprintln!("skipping R3: Noto Color Emoji not installed on this host");
return;
}
let result = resolve(&lookup, None);
assert!(
matches!(
result,
EmojiTypeface::Resolved {
family: EmojiFamily::NotoColorEmoji,
..
}
),
"expected Resolved(NotoColorEmoji), got {result:?}"
);
}
#[test]
fn r4_unavailable_reports_full_attempted_chain() {
let mock = MockLookup::new(vec![]);
let result = resolve(&mock, Some(EmojiFamily::SegoeUiEmoji));
match result {
EmojiTypeface::Unavailable { attempted } => {
assert_eq!(attempted.first(), Some(&EmojiFamily::SegoeUiEmoji));
let host_chain: &[EmojiFamily] = EmojiFamily::host_default();
let host_set: std::collections::HashSet<_> = host_chain.iter().copied().collect();
let attempted_set: std::collections::HashSet<_> =
attempted.iter().copied().collect();
for fam in &host_set {
assert!(
attempted_set.contains(fam),
"host default chain member {fam:?} must appear in attempted list"
);
}
}
other => panic!("expected Unavailable, got {other:?}"),
}
}
#[test]
fn r4b_attempted_list_dedupes_requested() {
let mock = MockLookup::new(vec![]);
let requested = EmojiFamily::host_default()
.first()
.copied()
.unwrap_or(EmojiFamily::NotoColorEmoji);
let result = resolve(&mock, Some(requested));
match result {
EmojiTypeface::Unavailable { attempted } => {
let count = attempted.iter().filter(|&&f| f == requested).count();
assert_eq!(count, 1, "requested family must appear exactly once");
}
_ => panic!("expected Unavailable"),
}
}
#[test]
fn r5_emoji_resolver_caches_repeated_calls() {
let mock = MockLookup::new(vec![]);
let calls_handle = std::rc::Rc::new(Cell::new(0usize));
struct CountingLookup {
calls: std::rc::Rc<Cell<usize>>,
}
impl EmojiTypefaceLookup for CountingLookup {
fn lookup(&self, _family: EmojiFamily) -> Option<TypefaceEntry> {
self.calls.set(self.calls.get() + 1);
None
}
}
drop(mock);
let resolver = EmojiResolver::new(CountingLookup {
calls: calls_handle.clone(),
});
let _ = resolver.resolve(None);
let after_first = calls_handle.get();
assert!(
after_first >= EmojiFamily::host_default().len(),
"first call must query every host default family, got {after_first} calls"
);
let _ = resolver.resolve(None);
assert_eq!(
calls_handle.get(),
after_first,
"EmojiResolver must dedupe repeat calls with the same `requested`"
);
assert_eq!(resolver.cached_count(), 1);
}
#[test]
fn r5b_distinct_requests_cache_independently() {
let resolver = EmojiResolver::new(MockLookup::new(vec![]));
let _ = resolver.resolve(None);
let _ = resolver.resolve(Some(EmojiFamily::SegoeUiEmoji));
let _ = resolver.resolve(Some(EmojiFamily::AppleColorEmoji));
assert_eq!(resolver.cached_count(), 3);
}
#[test]
fn r6_from_name_canonical() {
assert_eq!(
EmojiFamily::from_name_ci("Segoe UI Emoji"),
Some(EmojiFamily::SegoeUiEmoji)
);
}
#[test]
fn r7_from_name_non_emoji_returns_none() {
assert_eq!(EmojiFamily::from_name_ci("Calibri"), None);
assert_eq!(EmojiFamily::from_name_ci(""), None);
assert_eq!(EmojiFamily::from_name_ci("Arial Unicode MS"), None);
}
#[test]
fn r8_from_name_case_insensitive() {
assert_eq!(
EmojiFamily::from_name_ci("segoe ui emoji"),
Some(EmojiFamily::SegoeUiEmoji)
);
assert_eq!(
EmojiFamily::from_name_ci("APPLE COLOR EMOJI"),
Some(EmojiFamily::AppleColorEmoji)
);
assert_eq!(
EmojiFamily::from_name_ci("NoTo CoLoR eMoJi"),
Some(EmojiFamily::NotoColorEmoji)
);
}
#[test]
fn family_name_round_trip() {
for fam in [
EmojiFamily::SegoeUiEmoji,
EmojiFamily::AppleColorEmoji,
EmojiFamily::NotoColorEmoji,
EmojiFamily::TwitterColorEmoji,
EmojiFamily::JoyPixels,
EmojiFamily::EmojiOneColor,
] {
assert_eq!(EmojiFamily::from_name_ci(fam.family_name()), Some(fam));
}
}
#[test]
fn requested_match_short_circuits_chain() {
let target = EmojiFamily::host_default()
.first()
.copied()
.unwrap_or(EmojiFamily::NotoColorEmoji);
let mock = MockLookup::new(vec![target]);
let result = resolve(&mock, Some(target));
assert!(matches!(
result,
EmojiTypeface::Resolved { family, .. } if family == target
));
assert_eq!(
mock.calls.get(),
1,
"must not query host chain after requested matches"
);
}
}