//! Full-color emoji icons via the complete **Twemoji** 72ร—72 set. //! //! Any Unicode emoji (including ZWJ sequences, flags, skin tones) is mapped to //! a color PNG by codepoint โ€” not only a hand-picked half-dozen. Unknown or //! non-emoji text falls back to a monochrome label. //! //! ```ignore //! use vidya::{emoji_icon, icon, Icon, Theme}; //! //! emoji_icon(ui, &th, "๐Ÿš€", 20.0); //! emoji_icon(ui, &th, "๐Ÿ‘จโ€๐Ÿ’ป", 20.0); //! icon(ui, &th, Icon::Heart, 20.0); // convenience alias //! ``` //! //! Pack: `assets/emoji/twemoji-72x72.zip` (~3.8k glyphs). //! License: Twemoji CC-BY 4.0 โ€” see `assets/NOTICE`. use std::collections::HashMap; use std::io::{Cursor, Read}; use std::sync::OnceLock; use egui::{ load::SizedTexture, pos2, Align2, Color32, ColorImage, FontId, Id, Image, Rect, Response, Sense, Stroke, TextureHandle, TextureOptions, Ui, Vec2, }; use crate::Theme; /// Embedded Twemoji 72ร—72 pack (ZIP_STORED PNGs). static TWEMOJI_ZIP: &[u8] = include_bytes!("../assets/emoji/twemoji-72x72.zip"); /// Filename โ†’ PNG bytes, built once on first lookup. fn twemoji_pack() -> &'static HashMap> { static PACK: OnceLock>> = OnceLock::new(); PACK.get_or_init(|| { let mut map = HashMap::new(); let Ok(mut archive) = zip::ZipArchive::new(Cursor::new(TWEMOJI_ZIP)) else { return map; }; for i in 0..archive.len() { let Ok(mut file) = archive.by_index(i) else { continue; }; let name = file.name().to_string(); if !name.ends_with(".png") { continue; } // Skip directory entries / paths with separators (zip-slip). if name.contains('/') || name.contains('\\') { continue; } let mut buf = Vec::new(); if file.read_to_end(&mut buf).is_ok() { map.insert(name, buf); } } map }) } /// Named shortcuts for common reactions + UI chrome. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Icon { ThumbsUp, ThumbsDown, Heart, Laugh, Surprised, Frown, /// Drawn stroke โ€” not a Twemoji glyph. Plus, /// Drawn stroke โ€” overlapping pages (copy / duplicate). Copy, } impl Icon { pub const EMOJI: &'static [Icon] = &[ Icon::ThumbsUp, Icon::Heart, Icon::Laugh, Icon::Surprised, Icon::Frown, Icon::ThumbsDown, ]; pub const ALL: &'static [Icon] = &[ Icon::ThumbsUp, Icon::Heart, Icon::Laugh, Icon::Surprised, Icon::Frown, Icon::ThumbsDown, Icon::Plus, Icon::Copy, ]; /// True when this icon is stroke-drawn (not a Twemoji bitmap). pub fn is_stroke(self) -> bool { matches!(self, Icon::Plus | Icon::Copy) } pub fn emoji(self) -> &'static str { match self { Icon::ThumbsUp => "๐Ÿ‘", Icon::ThumbsDown => "๐Ÿ‘Ž", Icon::Heart => "โค๏ธ", Icon::Laugh => "๐Ÿ˜‚", Icon::Surprised => "๐Ÿ˜ฎ", Icon::Frown => "๐Ÿ˜ข", Icon::Plus => "+", Icon::Copy => "โง‰", } } } /// Strip only pure presentation noise (kept for callers / labels). pub fn normalize_emoji(emoji: &str) -> String { emoji .chars() .filter(|&c| c != '\u{FE0E}' && c != '\u{FE0F}') .collect() } /// Map a well-known short emoji to [`Icon`] (convenience). Prefer [`emoji_icon`] /// for arbitrary reactions โ€” that covers the full Twemoji set. pub fn icon_for_emoji(emoji: &str) -> Option { let key: String = emoji .chars() .filter(|&c| { c != '\u{FE0E}' && c != '\u{FE0F}' && c != '\u{200D}' && !matches!(c, '\u{1F3FB}'..='\u{1F3FF}') }) .collect(); match key.as_str() { "๐Ÿ‘" | "+1" => Some(Icon::ThumbsUp), "๐Ÿ‘Ž" | "-1" => Some(Icon::ThumbsDown), "โค" | "โ™ฅ" => Some(Icon::Heart), "๐Ÿ˜‚" | "๐Ÿ˜„" | "๐Ÿ˜†" | "๐Ÿคฃ" => Some(Icon::Laugh), "๐Ÿ˜ฎ" | "๐Ÿ˜ฏ" | "๐Ÿ˜ฒ" => Some(Icon::Surprised), "๐Ÿ˜ข" | "๐Ÿ˜ž" | "๐Ÿ™" | "โ˜น" | "๐Ÿ˜ญ" => Some(Icon::Frown), _ => None, } } /// True when we have a color Twemoji bitmap for this string. pub fn has_emoji_icon(emoji: &str) -> bool { twemoji_png(emoji).is_some() } /// How many glyphs are in the embedded pack (for demos / diagnostics). pub fn emoji_pack_len() -> usize { twemoji_pack().len() } // โ”€โ”€ Twemoji codepoint keys (matches twemoji.js grabTheRightIcon) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const ZWJ: char = '\u{200D}'; const VS16: char = '\u{FE0F}'; const VS15: char = '\u{FE0E}'; /// Twemoji file stem: lowercase hex codepoints joined by `-`. /// /// Rule from twemoji: if the sequence has **no** ZWJ, strip FE0F/FE0E; /// if it has ZWJ, keep variation selectors (filenames include `fe0f`). pub fn twemoji_key(emoji: &str) -> String { let has_zwj = emoji.contains(ZWJ); let chars = emoji.chars().filter(|&c| { if has_zwj { true } else { c != VS16 && c != VS15 } }); chars .map(|c| format!("{:x}", c as u32)) .collect::>() .join("-") } fn strip_skin_tones(emoji: &str) -> String { emoji .chars() .filter(|c| !matches!(c, '\u{1F3FB}'..='\u{1F3FF}')) .collect() } /// Candidate Twemoji stems, most specific first. fn twemoji_key_candidates(emoji: &str) -> Vec { let mut out = Vec::new(); let mut push = |s: String| { if !s.is_empty() && !out.contains(&s) { out.push(s); } }; let trimmed = emoji.trim(); if trimmed.is_empty() { return out; } push(twemoji_key(trimmed)); // Always also try VS-stripped form (covers assets that omit FE0F). let no_vs: String = trimmed .chars() .filter(|&c| c != VS16 && c != VS15) .collect(); push(twemoji_key(&no_vs)); // Force โ€œno ZWJ ruleโ€ key on the raw string by stripping VS. push( no_vs .chars() .map(|c| format!("{:x}", c as u32)) .collect::>() .join("-"), ); // Skin-tone fallback โ†’ base glyph. let no_skin = strip_skin_tones(trimmed); if no_skin != trimmed { push(twemoji_key(&no_skin)); let no_skin_vs: String = no_skin .chars() .filter(|&c| c != VS16 && c != VS15) .collect(); push(twemoji_key(&no_skin_vs)); } // First extended grapheme-ish: take until second ZWJ-less โ€œclusterโ€ โ€” for // multi-emoji paste, try the whole string then the first scalar sequence. // If still missing, try each scalar / ZWJ segment from the start. if trimmed.chars().count() > 1 { // Leading base only (first non-VS non-skin char + optional VS). if let Some(first) = trimmed.chars().find(|c| { *c != VS16 && *c != VS15 && !matches!(c, '\u{1F3FB}'..='\u{1F3FF}') && *c != ZWJ }) { push(format!("{:x}", first as u32)); } } out } /// PNG bytes for an emoji, if present in the pack. fn twemoji_png(emoji: &str) -> Option<&'static [u8]> { let pack = twemoji_pack(); for key in twemoji_key_candidates(emoji) { let name = format!("{key}.png"); if let Some(bytes) = pack.get(&name) { // Safe: pack is 'static, values live for process lifetime. return Some(bytes.as_slice()); } } None } // โ”€โ”€ painting โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ /// Paint a named icon at `size`ร—`size`. pub fn icon(ui: &mut Ui, theme: &Theme, icon: Icon, size: f32) -> Response { icon_colored(ui, theme.palette.text, icon, size) } /// Like [`icon`]; `color` only affects stroke icons ([`Icon::Plus`], [`Icon::Copy`]). pub fn icon_colored(ui: &mut Ui, color: Color32, icon: Icon, size: f32) -> Response { let size = size.max(8.0); match icon { Icon::Plus | Icon::Copy => paint_stroke_icon(ui, color, icon, size), other => { let (rect, response) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); paint_emoji_in(ui, rect, other.emoji(), color); response } } } /// Paint any emoji as a color Twemoji when available; else text fallback. pub fn emoji_icon(ui: &mut Ui, theme: &Theme, emoji: &str, size: f32) -> Response { emoji_icon_colored(ui, theme, theme.palette.text, emoji, size) } /// Like [`emoji_icon`]; `color` is only for text / Plus fallback. pub fn emoji_icon_colored( ui: &mut Ui, theme: &Theme, color: Color32, emoji: &str, size: f32, ) -> Response { let size = size.max(8.0); let (rect, response) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); paint_emoji_in(ui, rect, emoji, color); let _ = theme; response } /// Draw a stroke icon ([`Icon::Plus`], [`Icon::Copy`]) into `rect`. /// Emoji icons need [`paint_emoji_in`] / [`paint_icon_in`]. pub fn paint_icon(painter: &egui::Painter, rect: Rect, icon: Icon, color: Color32) { let s = rect.width().min(rect.height()); let c = rect.center(); let w = (s * 0.12).clamp(1.5, 2.5); let stroke = Stroke::new(w, color); match icon { Icon::Plus => { let arm = s * 0.32; painter.line_segment([pos2(c.x - arm, c.y), pos2(c.x + arm, c.y)], stroke); painter.line_segment([pos2(c.x, c.y - arm), pos2(c.x, c.y + arm)], stroke); } Icon::Copy => { // Two overlapping pages โ€” classic โ€œcopyโ€ glyph (stroke only). let page_w = s * 0.38; let page_h = s * 0.46; let r = (s * 0.08).clamp(0.5, 2.5); let dx = s * 0.11; let dy = s * 0.11; let back = Rect::from_center_size(pos2(c.x - dx, c.y - dy), Vec2::new(page_w, page_h)); let front = Rect::from_center_size(pos2(c.x + dx, c.y + dy), Vec2::new(page_w, page_h)); painter.rect_stroke(back, r, stroke, egui::StrokeKind::Inside); painter.rect_stroke(front, r, stroke, egui::StrokeKind::Inside); } _ => {} } } /// Draw a named [`Icon`] into `rect`. pub fn paint_icon_in(ui: &Ui, rect: Rect, icon: Icon, color: Color32) { match icon { Icon::Plus | Icon::Copy => paint_icon(ui.painter(), rect, icon, color), other => paint_emoji_in(ui, rect, other.emoji(), color), } } /// Draw any emoji into `rect` (color Twemoji or text fallback). pub fn paint_emoji_in(ui: &Ui, rect: Rect, emoji: &str, color: Color32) { if !ui.is_rect_visible(rect) { return; } if let Some(tex) = load_emoji_texture(ui.ctx(), emoji) { Image::from_texture(SizedTexture::new(tex.id(), tex.size_vec2())) .fit_to_exact_size(rect.size()) .paint_at(ui, rect); return; } // Fallback: raw text (may be monochrome Noto / tofu). let shown = normalize_emoji(emoji); let size = rect.width().min(rect.height()); ui.painter().text( rect.center(), Align2::CENTER_CENTER, if shown.is_empty() { emoji } else { &shown }, FontId::proportional((size * 0.85).max(10.0)), color, ); } /// Themed reaction chip: color emoji + optional count, at the caption size. pub fn reaction_chip( ui: &mut Ui, theme: &Theme, emoji: &str, count: usize, mine: bool, ) -> Response { let icon_size = (theme.type_scale.caption * 1.25).max(16.0); reaction_chip_sized(ui, theme, emoji, count, mine, icon_size) } /// Like [`reaction_chip`], with the glyph drawn at `icon_size` points. /// /// The pill around it is sized from the glyph rather than from the theme, so a /// small chip is small all through instead of a small picture adrift in a /// caption-sized pill. pub fn reaction_chip_sized( ui: &mut Ui, theme: &Theme, emoji: &str, count: usize, mine: bool, icon_size: f32, ) -> Response { let p = &theme.palette; let icon_size = icon_size.max(8.0); let fill = if mine { p.accent.gamma_multiply(0.35) } else { p.headerbar_bg }; let border = if mine { Stroke::new(1.0_f32, p.accent.gamma_multiply(0.7)) } else { Stroke::new(1.0_f32, p.border_soft) }; let inner = egui::Frame::new() .fill(fill) .stroke(border) .corner_radius(icon_size * 0.75) .inner_margin(egui::Margin::symmetric( (icon_size * 0.5) as i8, (icon_size * 0.2) as i8, )) .show(ui, |ui| { ui.horizontal(|ui| { // Spacing is allocated by hand below, so that a chip showing a // number and one that is not can be laid out to the same width // without the gap between items being counted a different // number of times in each. ui.spacing_mut().item_spacing.x = 0.0; let text_size = theme.type_scale.caption.min(icon_size); let gap = (icon_size * 0.25).max(2.0); // Two digits, which is as far as a reaction count usually // goes, so one, nine and ninety-nine are all the same chip: a // chip that grew when a second person arrived would shuffle // every chip beside it along the row, and a row of reactions is // something people aim at. let slot = text_size * 1.2; let space = |ui: &mut Ui, w: f32| { if w > 0.0 { ui.allocate_exact_size(Vec2::new(w, icon_size), Sense::hover()); } }; match count { // Not a reaction but the offer of one โ€” what a picker is // made of. Nothing to count, so nothing is set aside for a // count and the glyph has the pill to itself. 0 => { emoji_icon(ui, theme, emoji, icon_size); } // One reactor, and the "1" goes without saying. The room it // would have taken is split either side of the glyph rather // than left hanging off the end: the chip is the width of // one that does show a number, and still centred. 1 => { space(ui, (gap + slot) / 2.0); emoji_icon(ui, theme, emoji, icon_size); space(ui, (gap + slot) / 2.0); } n => { emoji_icon(ui, theme, emoji, icon_size); space(ui, gap); let (rect, _) = ui.allocate_exact_size(Vec2::new(slot, icon_size), Sense::hover()); ui.painter().text( rect.center(), Align2::CENTER_CENTER, n.to_string(), FontId::proportional(text_size), p.text, ); } } }); }); // A frame's own response only senses hover, so a chip built from one is // unclickable however it looks โ€” and a reaction chip is a button: clicking // it is how a reaction is put on or taken off. `interact` is what gives the // rect the frame occupies a click to report. let response = inner.response.interact(Sense::click()); if response.hovered() { ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); } response } // โ”€โ”€ texture cache โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ fn load_emoji_texture(ctx: &egui::Context, emoji: &str) -> Option { let key = twemoji_key_candidates(emoji).into_iter().next()?; // Cache by resolved pack key so โค๏ธ and โค share a texture when they hit the // same file; we re-resolve via png bytes identity below. let png = twemoji_png(emoji)?; // Stable id from the actual file we loaded (content address via key candidates). let file_key = twemoji_key_candidates(emoji) .into_iter() .find(|k| twemoji_pack().contains_key(&format!("{k}.png"))) .unwrap_or(key); let cache_id = Id::new(("vidya/twemoji", file_key.as_str())); if let Some(tex) = ctx.data(|d| d.get_temp::(cache_id)) { return Some(tex); } let image = decode_png_rgba(png)?; let handle = ctx.load_texture( format!("vidya/twemoji/{file_key}"), image, TextureOptions::LINEAR, ); ctx.data_mut(|d| d.insert_temp(cache_id, handle.clone())); Some(handle) } fn decode_png_rgba(bytes: &[u8]) -> Option { let mut decoder = png::Decoder::new(Cursor::new(bytes)); decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::ALPHA); let mut reader = decoder.read_info().ok()?; let mut buf = vec![0; reader.output_buffer_size()]; let info = reader.next_frame(&mut buf).ok()?; let w = info.width as usize; let h = info.height as usize; let raw = &buf[..info.buffer_size()]; let rgba: Vec = match info.color_type { png::ColorType::Rgba => raw.to_vec(), png::ColorType::Rgb => { let mut out = Vec::with_capacity(w * h * 4); for chunk in raw.chunks_exact(3) { out.extend_from_slice(&[chunk[0], chunk[1], chunk[2], 255]); } out } _ => return None, }; if rgba.len() != w * h * 4 { return None; } Some(ColorImage::from_rgba_unmultiplied([w, h], &rgba)) } fn paint_stroke_icon(ui: &mut Ui, color: Color32, icon: Icon, size: f32) -> Response { let (rect, response) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); if ui.is_rect_visible(rect) { paint_icon(ui.painter(), rect, icon, color); } response } #[cfg(test)] mod tests { use super::*; #[test] fn pack_is_populated() { let n = emoji_pack_len(); assert!(n > 3000, "expected full Twemoji set, got {n}"); } #[test] fn keys_match_twemoji_filenames() { assert_eq!(twemoji_key("๐Ÿ‘"), "1f44d"); assert_eq!(twemoji_key("โค๏ธ"), "2764"); // VS16 stripped (no ZWJ) assert_eq!(twemoji_key("๐Ÿ˜‚"), "1f602"); assert_eq!(twemoji_key("๐Ÿš€"), "1f680"); // ZWJ sequence keeps fe0f when present in input let technologist = "๐Ÿ‘จ\u{200D}๐Ÿ’ป"; assert_eq!(twemoji_key(technologist), "1f468-200d-1f4bb"); } /// A chip nobody can click is a picture of a button. The frame it is built /// from only senses hover on its own, so this is the regression that /// matters: reacting is a click on this widget. #[test] fn chip_senses_clicks() { let theme = Theme::dark(); let ctx = egui::Context::default(); let mut sense = Sense::hover(); let _ = ctx.run(Default::default(), |ctx| { egui::CentralPanel::default().show(ctx, |ui| { sense = reaction_chip(ui, &theme, "\u{1f44d}", 2, false).sense; }); }); assert!(sense.senses_click(), "reaction chip does not sense clicks"); } #[test] fn resolves_common_and_rare() { for e in [ "๐Ÿ‘", "โค๏ธ", "โค", "๐Ÿ˜‚", "๐Ÿ˜ฎ", "๐Ÿ˜ข", "๐Ÿ‘Ž", "๐Ÿš€", "๐ŸŽ‰", "๐Ÿ”ฅ", "โœจ", "๐Ÿ‘€", "๐Ÿ’ฏ", "๐Ÿ™", "๐Ÿ˜Ž", "๐Ÿคฏ", "๐Ÿณ๏ธ", "๐Ÿด", ] { assert!( has_emoji_icon(e), "missing Twemoji for {e:?} key={:?}", twemoji_key_candidates(e) ); } } #[test] fn skin_tone_falls_back_to_base() { // ๐Ÿ‘๐Ÿป โ†’ base ๐Ÿ‘ asset assert!(has_emoji_icon("๐Ÿ‘๐Ÿป")); } #[test] fn zwj_sequence() { assert!(has_emoji_icon("๐Ÿ‘จโ€๐Ÿ’ป"), "technologist ZWJ"); } #[test] fn unknown_text_has_no_icon() { assert!(!has_emoji_icon("hello")); assert!(!has_emoji_icon("")); } #[test] fn icon_shortcuts() { assert_eq!(icon_for_emoji("๐Ÿ‘"), Some(Icon::ThumbsUp)); assert_eq!(icon_for_emoji("โค๏ธ"), Some(Icon::Heart)); } #[test] fn pngs_decode() { for e in ["๐Ÿ‘", "โค๏ธ", "๐Ÿ˜‚", "๐Ÿš€", "๐Ÿ‘จโ€๐Ÿ’ป"] { let png = twemoji_png(e).unwrap_or_else(|| panic!("no png for {e}")); let img = decode_png_rgba(png).unwrap_or_else(|| panic!("decode {e}")); assert_eq!(img.size, [72, 72]); } } }