| Bring vidya in cfd3e36 nandi 19d ago | 1 | //! Full-color emoji icons via the complete **Twemoji** 72×72 set. |
| 2 | //! |
| 3 | //! Any Unicode emoji (including ZWJ sequences, flags, skin tones) is mapped to |
| 4 | //! a color PNG by codepoint — not only a hand-picked half-dozen. Unknown or |
| 5 | //! non-emoji text falls back to a monochrome label. |
| 6 | //! |
| 7 | //! ```ignore |
| 8 | //! use vidya::{emoji_icon, icon, Icon, Theme}; |
| 9 | //! |
| 10 | //! emoji_icon(ui, &th, "🚀", 20.0); |
| 11 | //! emoji_icon(ui, &th, "👨💻", 20.0); |
| 12 | //! icon(ui, &th, Icon::Heart, 20.0); // convenience alias |
| 13 | //! ``` |
| 14 | //! |
| 15 | //! Pack: `assets/emoji/twemoji-72x72.zip` (~3.8k glyphs). |
| 16 | //! License: Twemoji CC-BY 4.0 — see `assets/NOTICE`. |
| 17 | |
| 18 | use std::collections::HashMap; |
| 19 | use std::io::{Cursor, Read}; |
| 20 | use std::sync::OnceLock; |
| 21 | |
| 22 | use egui::{ |
| 23 | load::SizedTexture, pos2, Align2, Color32, ColorImage, FontId, Id, Image, Rect, Response, |
| 24 | Sense, Stroke, TextureHandle, TextureOptions, Ui, Vec2, |
| 25 | }; |
| 26 | |
| 27 | use crate::Theme; |
| 28 | |
| 29 | /// Embedded Twemoji 72×72 pack (ZIP_STORED PNGs). |
| 30 | static TWEMOJI_ZIP: &[u8] = include_bytes!("../assets/emoji/twemoji-72x72.zip"); |
| 31 | |
| 32 | /// Filename → PNG bytes, built once on first lookup. |
| 33 | fn twemoji_pack() -> &'static HashMap<String, Vec<u8>> { |
| 34 | static PACK: OnceLock<HashMap<String, Vec<u8>>> = OnceLock::new(); |
| 35 | PACK.get_or_init(|| { |
| 36 | let mut map = HashMap::new(); |
| 37 | let Ok(mut archive) = zip::ZipArchive::new(Cursor::new(TWEMOJI_ZIP)) else { |
| 38 | return map; |
| 39 | }; |
| 40 | for i in 0..archive.len() { |
| 41 | let Ok(mut file) = archive.by_index(i) else { |
| 42 | continue; |
| 43 | }; |
| 44 | let name = file.name().to_string(); |
| 45 | if !name.ends_with(".png") { |
| 46 | continue; |
| 47 | } |
| 48 | // Skip directory entries / paths with separators (zip-slip). |
| 49 | if name.contains('/') || name.contains('\\') { |
| 50 | continue; |
| 51 | } |
| 52 | let mut buf = Vec::new(); |
| 53 | if file.read_to_end(&mut buf).is_ok() { |
| 54 | map.insert(name, buf); |
| 55 | } |
| 56 | } |
| 57 | map |
| 58 | }) |
| 59 | } |
| 60 | |
| 61 | /// Named shortcuts for common reactions + UI chrome. |
| 62 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] |
| 63 | pub enum Icon { |
| 64 | ThumbsUp, |
| 65 | ThumbsDown, |
| 66 | Heart, |
| 67 | Laugh, |
| 68 | Surprised, |
| 69 | Frown, |
| 70 | /// Drawn stroke — not a Twemoji glyph. |
| 71 | Plus, |
| 72 | /// Drawn stroke — overlapping pages (copy / duplicate). |
| 73 | Copy, |
| 74 | } |
| 75 | |
| 76 | impl Icon { |
| 77 | pub const EMOJI: &'static [Icon] = &[ |
| 78 | Icon::ThumbsUp, |
| 79 | Icon::Heart, |
| 80 | Icon::Laugh, |
| 81 | Icon::Surprised, |
| 82 | Icon::Frown, |
| 83 | Icon::ThumbsDown, |
| 84 | ]; |
| 85 | |
| 86 | pub const ALL: &'static [Icon] = &[ |
| 87 | Icon::ThumbsUp, |
| 88 | Icon::Heart, |
| 89 | Icon::Laugh, |
| 90 | Icon::Surprised, |
| 91 | Icon::Frown, |
| 92 | Icon::ThumbsDown, |
| 93 | Icon::Plus, |
| 94 | Icon::Copy, |
| 95 | ]; |
| 96 | |
| 97 | /// True when this icon is stroke-drawn (not a Twemoji bitmap). |
| 98 | pub fn is_stroke(self) -> bool { |
| 99 | matches!(self, Icon::Plus | Icon::Copy) |
| 100 | } |
| 101 | |
| 102 | pub fn emoji(self) -> &'static str { |
| 103 | match self { |
| 104 | Icon::ThumbsUp => "👍", |
| 105 | Icon::ThumbsDown => "👎", |
| 106 | Icon::Heart => "❤️", |
| 107 | Icon::Laugh => "😂", |
| 108 | Icon::Surprised => "😮", |
| 109 | Icon::Frown => "😢", |
| 110 | Icon::Plus => "+", |
| 111 | Icon::Copy => "⧉", |
| 112 | } |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | /// Strip only pure presentation noise (kept for callers / labels). |
| 117 | pub fn normalize_emoji(emoji: &str) -> String { |
| 118 | emoji |
| 119 | .chars() |
| 120 | .filter(|&c| c != '\u{FE0E}' && c != '\u{FE0F}') |
| 121 | .collect() |
| 122 | } |
| 123 | |
| 124 | /// Map a well-known short emoji to [`Icon`] (convenience). Prefer [`emoji_icon`] |
| 125 | /// for arbitrary reactions — that covers the full Twemoji set. |
| 126 | pub fn icon_for_emoji(emoji: &str) -> Option<Icon> { |
| 127 | let key: String = emoji |
| 128 | .chars() |
| 129 | .filter(|&c| { |
| 130 | c != '\u{FE0E}' |
| 131 | && c != '\u{FE0F}' |
| 132 | && c != '\u{200D}' |
| 133 | && !matches!(c, '\u{1F3FB}'..='\u{1F3FF}') |
| 134 | }) |
| 135 | .collect(); |
| 136 | match key.as_str() { |
| 137 | "👍" | "+1" => Some(Icon::ThumbsUp), |
| 138 | "👎" | "-1" => Some(Icon::ThumbsDown), |
| 139 | "❤" | "♥" => Some(Icon::Heart), |
| 140 | "😂" | "😄" | "😆" | "🤣" => Some(Icon::Laugh), |
| 141 | "😮" | "😯" | "😲" => Some(Icon::Surprised), |
| 142 | "😢" | "😞" | "🙁" | "☹" | "😭" => Some(Icon::Frown), |
| 143 | _ => None, |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | /// True when we have a color Twemoji bitmap for this string. |
| 148 | pub fn has_emoji_icon(emoji: &str) -> bool { |
| 149 | twemoji_png(emoji).is_some() |
| 150 | } |
| 151 | |
| 152 | /// How many glyphs are in the embedded pack (for demos / diagnostics). |
| 153 | pub fn emoji_pack_len() -> usize { |
| 154 | twemoji_pack().len() |
| 155 | } |
| 156 | |
| 157 | // ── Twemoji codepoint keys (matches twemoji.js grabTheRightIcon) ──────────── |
| 158 | |
| 159 | const ZWJ: char = '\u{200D}'; |
| 160 | const VS16: char = '\u{FE0F}'; |
| 161 | const VS15: char = '\u{FE0E}'; |
| 162 | |
| 163 | /// Twemoji file stem: lowercase hex codepoints joined by `-`. |
| 164 | /// |
| 165 | /// Rule from twemoji: if the sequence has **no** ZWJ, strip FE0F/FE0E; |
| 166 | /// if it has ZWJ, keep variation selectors (filenames include `fe0f`). |
| 167 | pub fn twemoji_key(emoji: &str) -> String { |
| 168 | let has_zwj = emoji.contains(ZWJ); |
| 169 | let chars = emoji.chars().filter(|&c| { |
| 170 | if has_zwj { |
| 171 | true |
| 172 | } else { |
| 173 | c != VS16 && c != VS15 |
| 174 | } |
| 175 | }); |
| 176 | chars |
| 177 | .map(|c| format!("{:x}", c as u32)) |
| 178 | .collect::<Vec<_>>() |
| 179 | .join("-") |
| 180 | } |
| 181 | |
| 182 | fn strip_skin_tones(emoji: &str) -> String { |
| 183 | emoji |
| 184 | .chars() |
| 185 | .filter(|c| !matches!(c, '\u{1F3FB}'..='\u{1F3FF}')) |
| 186 | .collect() |
| 187 | } |
| 188 | |
| 189 | /// Candidate Twemoji stems, most specific first. |
| 190 | fn twemoji_key_candidates(emoji: &str) -> Vec<String> { |
| 191 | let mut out = Vec::new(); |
| 192 | let mut push = |s: String| { |
| 193 | if !s.is_empty() && !out.contains(&s) { |
| 194 | out.push(s); |
| 195 | } |
| 196 | }; |
| 197 | |
| 198 | let trimmed = emoji.trim(); |
| 199 | if trimmed.is_empty() { |
| 200 | return out; |
| 201 | } |
| 202 | |
| 203 | push(twemoji_key(trimmed)); |
| 204 | |
| 205 | // Always also try VS-stripped form (covers assets that omit FE0F). |
| 206 | let no_vs: String = trimmed |
| 207 | .chars() |
| 208 | .filter(|&c| c != VS16 && c != VS15) |
| 209 | .collect(); |
| 210 | push(twemoji_key(&no_vs)); |
| 211 | // Force “no ZWJ rule” key on the raw string by stripping VS. |
| 212 | push( |
| 213 | no_vs |
| 214 | .chars() |
| 215 | .map(|c| format!("{:x}", c as u32)) |
| 216 | .collect::<Vec<_>>() |
| 217 | .join("-"), |
| 218 | ); |
| 219 | |
| 220 | // Skin-tone fallback → base glyph. |
| 221 | let no_skin = strip_skin_tones(trimmed); |
| 222 | if no_skin != trimmed { |
| 223 | push(twemoji_key(&no_skin)); |
| 224 | let no_skin_vs: String = no_skin |
| 225 | .chars() |
| 226 | .filter(|&c| c != VS16 && c != VS15) |
| 227 | .collect(); |
| 228 | push(twemoji_key(&no_skin_vs)); |
| 229 | } |
| 230 | |
| 231 | // First extended grapheme-ish: take until second ZWJ-less “cluster” — for |
| 232 | // multi-emoji paste, try the whole string then the first scalar sequence. |
| 233 | // If still missing, try each scalar / ZWJ segment from the start. |
| 234 | if trimmed.chars().count() > 1 { |
| 235 | // Leading base only (first non-VS non-skin char + optional VS). |
| 236 | if let Some(first) = trimmed.chars().find(|c| { |
| 237 | *c != VS16 && *c != VS15 && !matches!(c, '\u{1F3FB}'..='\u{1F3FF}') && *c != ZWJ |
| 238 | }) { |
| 239 | push(format!("{:x}", first as u32)); |
| 240 | } |
| 241 | } |
| 242 | |
| 243 | out |
| 244 | } |
| 245 | |
| 246 | /// PNG bytes for an emoji, if present in the pack. |
| 247 | fn twemoji_png(emoji: &str) -> Option<&'static [u8]> { |
| 248 | let pack = twemoji_pack(); |
| 249 | for key in twemoji_key_candidates(emoji) { |
| 250 | let name = format!("{key}.png"); |
| 251 | if let Some(bytes) = pack.get(&name) { |
| 252 | // Safe: pack is 'static, values live for process lifetime. |
| 253 | return Some(bytes.as_slice()); |
| 254 | } |
| 255 | } |
| 256 | None |
| 257 | } |
| 258 | |
| 259 | // ── painting ──────────────────────────────────────────────────────────────── |
| 260 | |
| 261 | /// Paint a named icon at `size`×`size`. |
| 262 | pub fn icon(ui: &mut Ui, theme: &Theme, icon: Icon, size: f32) -> Response { |
| 263 | icon_colored(ui, theme.palette.text, icon, size) |
| 264 | } |
| 265 | |
| 266 | /// Like [`icon`]; `color` only affects stroke icons ([`Icon::Plus`], [`Icon::Copy`]). |
| 267 | pub fn icon_colored(ui: &mut Ui, color: Color32, icon: Icon, size: f32) -> Response { |
| 268 | let size = size.max(8.0); |
| 269 | match icon { |
| 270 | Icon::Plus | Icon::Copy => paint_stroke_icon(ui, color, icon, size), |
| 271 | other => { |
| 272 | let (rect, response) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); |
| 273 | paint_emoji_in(ui, rect, other.emoji(), color); |
| 274 | response |
| 275 | } |
| 276 | } |
| 277 | } |
| 278 | |
| 279 | /// Paint any emoji as a color Twemoji when available; else text fallback. |
| 280 | pub fn emoji_icon(ui: &mut Ui, theme: &Theme, emoji: &str, size: f32) -> Response { |
| 281 | emoji_icon_colored(ui, theme, theme.palette.text, emoji, size) |
| 282 | } |
| 283 | |
| 284 | /// Like [`emoji_icon`]; `color` is only for text / Plus fallback. |
| 285 | pub fn emoji_icon_colored( |
| 286 | ui: &mut Ui, |
| 287 | theme: &Theme, |
| 288 | color: Color32, |
| 289 | emoji: &str, |
| 290 | size: f32, |
| 291 | ) -> Response { |
| 292 | let size = size.max(8.0); |
| 293 | let (rect, response) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); |
| 294 | paint_emoji_in(ui, rect, emoji, color); |
| 295 | let _ = theme; |
| 296 | response |
| 297 | } |
| 298 | |
| 299 | /// Draw a stroke icon ([`Icon::Plus`], [`Icon::Copy`]) into `rect`. |
| 300 | /// Emoji icons need [`paint_emoji_in`] / [`paint_icon_in`]. |
| 301 | pub fn paint_icon(painter: &egui::Painter, rect: Rect, icon: Icon, color: Color32) { |
| 302 | let s = rect.width().min(rect.height()); |
| 303 | let c = rect.center(); |
| 304 | let w = (s * 0.12).clamp(1.5, 2.5); |
| 305 | let stroke = Stroke::new(w, color); |
| 306 | match icon { |
| 307 | Icon::Plus => { |
| 308 | let arm = s * 0.32; |
| 309 | painter.line_segment([pos2(c.x - arm, c.y), pos2(c.x + arm, c.y)], stroke); |
| 310 | painter.line_segment([pos2(c.x, c.y - arm), pos2(c.x, c.y + arm)], stroke); |
| 311 | } |
| 312 | Icon::Copy => { |
| 313 | // Two overlapping pages — classic “copy” glyph (stroke only). |
| 314 | let page_w = s * 0.38; |
| 315 | let page_h = s * 0.46; |
| 316 | let r = (s * 0.08).clamp(0.5, 2.5); |
| 317 | let dx = s * 0.11; |
| 318 | let dy = s * 0.11; |
| 319 | let back = Rect::from_center_size(pos2(c.x - dx, c.y - dy), Vec2::new(page_w, page_h)); |
| 320 | let front = Rect::from_center_size(pos2(c.x + dx, c.y + dy), Vec2::new(page_w, page_h)); |
| 321 | painter.rect_stroke(back, r, stroke, egui::StrokeKind::Inside); |
| 322 | painter.rect_stroke(front, r, stroke, egui::StrokeKind::Inside); |
| 323 | } |
| 324 | _ => {} |
| 325 | } |
| 326 | } |
| 327 | |
| 328 | /// Draw a named [`Icon`] into `rect`. |
| 329 | pub fn paint_icon_in(ui: &Ui, rect: Rect, icon: Icon, color: Color32) { |
| 330 | match icon { |
| 331 | Icon::Plus | Icon::Copy => paint_icon(ui.painter(), rect, icon, color), |
| 332 | other => paint_emoji_in(ui, rect, other.emoji(), color), |
| 333 | } |
| 334 | } |
| 335 | |
| 336 | /// Draw any emoji into `rect` (color Twemoji or text fallback). |
| 337 | pub fn paint_emoji_in(ui: &Ui, rect: Rect, emoji: &str, color: Color32) { |
| 338 | if !ui.is_rect_visible(rect) { |
| 339 | return; |
| 340 | } |
| 341 | if let Some(tex) = load_emoji_texture(ui.ctx(), emoji) { |
| 342 | Image::from_texture(SizedTexture::new(tex.id(), tex.size_vec2())) |
| 343 | .fit_to_exact_size(rect.size()) |
| 344 | .paint_at(ui, rect); |
| 345 | return; |
| 346 | } |
| 347 | // Fallback: raw text (may be monochrome Noto / tofu). |
| 348 | let shown = normalize_emoji(emoji); |
| 349 | let size = rect.width().min(rect.height()); |
| 350 | ui.painter().text( |
| 351 | rect.center(), |
| 352 | Align2::CENTER_CENTER, |
| 353 | if shown.is_empty() { emoji } else { &shown }, |
| 354 | FontId::proportional((size * 0.85).max(10.0)), |
| 355 | color, |
| 356 | ); |
| 357 | } |
| 358 | |
| 359 | /// Themed reaction chip: color emoji + optional count, at the caption size. |
| 360 | pub fn reaction_chip( |
| 361 | ui: &mut Ui, |
| 362 | theme: &Theme, |
| 363 | emoji: &str, |
| 364 | count: usize, |
| 365 | mine: bool, |
| 366 | ) -> Response { |
| 367 | let icon_size = (theme.type_scale.caption * 1.25).max(16.0); |
| 368 | reaction_chip_sized(ui, theme, emoji, count, mine, icon_size) |
| 369 | } |
| 370 | |
| 371 | /// Like [`reaction_chip`], with the glyph drawn at `icon_size` points. |
| 372 | /// |
| 373 | /// The pill around it is sized from the glyph rather than from the theme, so a |
| 374 | /// small chip is small all through instead of a small picture adrift in a |
| 375 | /// caption-sized pill. |
| 376 | pub fn reaction_chip_sized( |
| 377 | ui: &mut Ui, |
| 378 | theme: &Theme, |
| 379 | emoji: &str, |
| 380 | count: usize, |
| 381 | mine: bool, |
| 382 | icon_size: f32, |
| 383 | ) -> Response { |
| 384 | let p = &theme.palette; |
| 385 | let icon_size = icon_size.max(8.0); |
| 386 | let fill = if mine { |
| 387 | p.accent.gamma_multiply(0.35) |
| 388 | } else { |
| 389 | p.headerbar_bg |
| 390 | }; |
| 391 | let border = if mine { |
| 392 | Stroke::new(1.0_f32, p.accent.gamma_multiply(0.7)) |
| 393 | } else { |
| 394 | Stroke::new(1.0_f32, p.border_soft) |
| 395 | }; |
| 396 | let inner = egui::Frame::new() |
| 397 | .fill(fill) |
| 398 | .stroke(border) |
| 399 | .corner_radius(icon_size * 0.75) |
| 400 | .inner_margin(egui::Margin::symmetric( |
| 401 | (icon_size * 0.5) as i8, |
| 402 | (icon_size * 0.2) as i8, |
| 403 | )) |
| 404 | .show(ui, |ui| { |
| 405 | ui.horizontal(|ui| { |
| 406 | // Spacing is allocated by hand below, so that a chip showing a |
| 407 | // number and one that is not can be laid out to the same width |
| 408 | // without the gap between items being counted a different |
| 409 | // number of times in each. |
| 410 | ui.spacing_mut().item_spacing.x = 0.0; |
| 411 | let text_size = theme.type_scale.caption.min(icon_size); |
| 412 | let gap = (icon_size * 0.25).max(2.0); |
| 413 | // Two digits, which is as far as a reaction count usually |
| 414 | // goes, so one, nine and ninety-nine are all the same chip: a |
| 415 | // chip that grew when a second person arrived would shuffle |
| 416 | // every chip beside it along the row, and a row of reactions is |
| 417 | // something people aim at. |
| 418 | let slot = text_size * 1.2; |
| 419 | let space = |ui: &mut Ui, w: f32| { |
| 420 | if w > 0.0 { |
| 421 | ui.allocate_exact_size(Vec2::new(w, icon_size), Sense::hover()); |
| 422 | } |
| 423 | }; |
| 424 | match count { |
| 425 | // Not a reaction but the offer of one — what a picker is |
| 426 | // made of. Nothing to count, so nothing is set aside for a |
| 427 | // count and the glyph has the pill to itself. |
| 428 | 0 => { |
| 429 | emoji_icon(ui, theme, emoji, icon_size); |
| 430 | } |
| 431 | // One reactor, and the "1" goes without saying. The room it |
| 432 | // would have taken is split either side of the glyph rather |
| 433 | // than left hanging off the end: the chip is the width of |
| 434 | // one that does show a number, and still centred. |
| 435 | 1 => { |
| 436 | space(ui, (gap + slot) / 2.0); |
| 437 | emoji_icon(ui, theme, emoji, icon_size); |
| 438 | space(ui, (gap + slot) / 2.0); |
| 439 | } |
| 440 | n => { |
| 441 | emoji_icon(ui, theme, emoji, icon_size); |
| 442 | space(ui, gap); |
| 443 | let (rect, _) = |
| 444 | ui.allocate_exact_size(Vec2::new(slot, icon_size), Sense::hover()); |
| 445 | ui.painter().text( |
| 446 | rect.center(), |
| 447 | Align2::CENTER_CENTER, |
| 448 | n.to_string(), |
| 449 | FontId::proportional(text_size), |
| 450 | p.text, |
| 451 | ); |
| 452 | } |
| 453 | } |
| 454 | }); |
| 455 | }); |
| 456 | // A frame's own response only senses hover, so a chip built from one is |
| 457 | // unclickable however it looks — and a reaction chip is a button: clicking |
| 458 | // it is how a reaction is put on or taken off. `interact` is what gives the |
| 459 | // rect the frame occupies a click to report. |
| 460 | let response = inner.response.interact(Sense::click()); |
| 461 | if response.hovered() { |
| 462 | ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); |
| 463 | } |
| 464 | response |
| 465 | } |
| 466 | |
| 467 | // ── texture cache ─────────────────────────────────────────────────────────── |
| 468 | |
| 469 | fn load_emoji_texture(ctx: &egui::Context, emoji: &str) -> Option<TextureHandle> { |
| 470 | let key = twemoji_key_candidates(emoji).into_iter().next()?; |
| 471 | // Cache by resolved pack key so ❤️ and ❤ share a texture when they hit the |
| 472 | // same file; we re-resolve via png bytes identity below. |
| 473 | let png = twemoji_png(emoji)?; |
| 474 | // Stable id from the actual file we loaded (content address via key candidates). |
| 475 | let file_key = twemoji_key_candidates(emoji) |
| 476 | .into_iter() |
| 477 | .find(|k| twemoji_pack().contains_key(&format!("{k}.png"))) |
| 478 | .unwrap_or(key); |
| 479 | let cache_id = Id::new(("vidya/twemoji", file_key.as_str())); |
| 480 | |
| 481 | if let Some(tex) = ctx.data(|d| d.get_temp::<TextureHandle>(cache_id)) { |
| 482 | return Some(tex); |
| 483 | } |
| 484 | |
| 485 | let image = decode_png_rgba(png)?; |
| 486 | let handle = ctx.load_texture( |
| 487 | format!("vidya/twemoji/{file_key}"), |
| 488 | image, |
| 489 | TextureOptions::LINEAR, |
| 490 | ); |
| 491 | ctx.data_mut(|d| d.insert_temp(cache_id, handle.clone())); |
| 492 | Some(handle) |
| 493 | } |
| 494 | |
| 495 | fn decode_png_rgba(bytes: &[u8]) -> Option<ColorImage> { |
| 496 | let mut decoder = png::Decoder::new(Cursor::new(bytes)); |
| 497 | decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::ALPHA); |
| 498 | let mut reader = decoder.read_info().ok()?; |
| 499 | let mut buf = vec![0; reader.output_buffer_size()]; |
| 500 | let info = reader.next_frame(&mut buf).ok()?; |
| 501 | let w = info.width as usize; |
| 502 | let h = info.height as usize; |
| 503 | let raw = &buf[..info.buffer_size()]; |
| 504 | let rgba: Vec<u8> = match info.color_type { |
| 505 | png::ColorType::Rgba => raw.to_vec(), |
| 506 | png::ColorType::Rgb => { |
| 507 | let mut out = Vec::with_capacity(w * h * 4); |
| Clear the clippy backlog the new CI enforces 4956d1e nandi 13d ago | 508 | for chunk in raw.as_chunks::<3>().0 { |
| Bring vidya in cfd3e36 nandi 19d ago | 509 | out.extend_from_slice(&[chunk[0], chunk[1], chunk[2], 255]); |
| 510 | } |
| 511 | out |
| 512 | } |
| 513 | _ => return None, |
| 514 | }; |
| 515 | if rgba.len() != w * h * 4 { |
| 516 | return None; |
| 517 | } |
| 518 | Some(ColorImage::from_rgba_unmultiplied([w, h], &rgba)) |
| 519 | } |
| 520 | |
| 521 | fn paint_stroke_icon(ui: &mut Ui, color: Color32, icon: Icon, size: f32) -> Response { |
| 522 | let (rect, response) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); |
| 523 | if ui.is_rect_visible(rect) { |
| 524 | paint_icon(ui.painter(), rect, icon, color); |
| 525 | } |
| 526 | response |
| 527 | } |
| 528 | |
| 529 | #[cfg(test)] |
| 530 | mod tests { |
| 531 | use super::*; |
| 532 | |
| 533 | #[test] |
| 534 | fn pack_is_populated() { |
| 535 | let n = emoji_pack_len(); |
| 536 | assert!(n > 3000, "expected full Twemoji set, got {n}"); |
| 537 | } |
| 538 | |
| 539 | #[test] |
| 540 | fn keys_match_twemoji_filenames() { |
| 541 | assert_eq!(twemoji_key("👍"), "1f44d"); |
| 542 | assert_eq!(twemoji_key("❤️"), "2764"); // VS16 stripped (no ZWJ) |
| 543 | assert_eq!(twemoji_key("😂"), "1f602"); |
| 544 | assert_eq!(twemoji_key("🚀"), "1f680"); |
| 545 | // ZWJ sequence keeps fe0f when present in input |
| 546 | let technologist = "👨\u{200D}💻"; |
| 547 | assert_eq!(twemoji_key(technologist), "1f468-200d-1f4bb"); |
| 548 | } |
| 549 | |
| 550 | /// A chip nobody can click is a picture of a button. The frame it is built |
| 551 | /// from only senses hover on its own, so this is the regression that |
| 552 | /// matters: reacting is a click on this widget. |
| 553 | #[test] |
| 554 | fn chip_senses_clicks() { |
| 555 | let theme = Theme::dark(); |
| 556 | let ctx = egui::Context::default(); |
| 557 | let mut sense = Sense::hover(); |
| 558 | let _ = ctx.run(Default::default(), |ctx| { |
| 559 | egui::CentralPanel::default().show(ctx, |ui| { |
| 560 | sense = reaction_chip(ui, &theme, "\u{1f44d}", 2, false).sense; |
| 561 | }); |
| 562 | }); |
| 563 | assert!(sense.senses_click(), "reaction chip does not sense clicks"); |
| 564 | } |
| 565 | |
| 566 | #[test] |
| 567 | fn resolves_common_and_rare() { |
| 568 | for e in [ |
| 569 | "👍", "❤️", "❤", "😂", "😮", "😢", "👎", "🚀", "🎉", "🔥", "✨", "👀", "💯", "🙏", |
| 570 | "😎", "🤯", "🏳️", "🏴", |
| 571 | ] { |
| 572 | assert!( |
| 573 | has_emoji_icon(e), |
| 574 | "missing Twemoji for {e:?} key={:?}", |
| 575 | twemoji_key_candidates(e) |
| 576 | ); |
| 577 | } |
| 578 | } |
| 579 | |
| 580 | #[test] |
| 581 | fn skin_tone_falls_back_to_base() { |
| 582 | // 👍🏻 → base 👍 asset |
| 583 | assert!(has_emoji_icon("👍🏻")); |
| 584 | } |
| 585 | |
| 586 | #[test] |
| 587 | fn zwj_sequence() { |
| 588 | assert!(has_emoji_icon("👨💻"), "technologist ZWJ"); |
| 589 | } |
| 590 | |
| 591 | #[test] |
| 592 | fn unknown_text_has_no_icon() { |
| 593 | assert!(!has_emoji_icon("hello")); |
| 594 | assert!(!has_emoji_icon("")); |
| 595 | } |
| 596 | |
| 597 | #[test] |
| 598 | fn icon_shortcuts() { |
| 599 | assert_eq!(icon_for_emoji("👍"), Some(Icon::ThumbsUp)); |
| 600 | assert_eq!(icon_for_emoji("❤️"), Some(Icon::Heart)); |
| 601 | } |
| 602 | |
| 603 | #[test] |
| 604 | fn pngs_decode() { |
| 605 | for e in ["👍", "❤️", "😂", "🚀", "👨💻"] { |
| 606 | let png = twemoji_png(e).unwrap_or_else(|| panic!("no png for {e}")); |
| 607 | let img = decode_png_rgba(png).unwrap_or_else(|| panic!("decode {e}")); |
| 608 | assert_eq!(img.size, [72, 72]); |
| 609 | } |
| 610 | } |
| 611 | } |