nandi/jolt-nativepublic Fork 0
121e5f1d751e8003ea229e70debf486b9bea3286
Commits
Clone
git clone https://git.rickub.com/nandi/jolt-native.git
git clone ssh://git@rickub.com/nandi/jolt-native.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

icons.rs · 611 lines · 20.7 KBRust Blame HistoryRaw
Bring vidya in cfd3e36 nandi 19d ago1//! 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
18use std::collections::HashMap;
19use std::io::{Cursor, Read};
20use std::sync::OnceLock;
21
22use egui::{
23 load::SizedTexture, pos2, Align2, Color32, ColorImage, FontId, Id, Image, Rect, Response,
24 Sense, Stroke, TextureHandle, TextureOptions, Ui, Vec2,
25};
26
27use crate::Theme;
28
29/// Embedded Twemoji 72×72 pack (ZIP_STORED PNGs).
30static TWEMOJI_ZIP: &[u8] = include_bytes!("../assets/emoji/twemoji-72x72.zip");
31
32/// Filename → PNG bytes, built once on first lookup.
33fn 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)]
63pub 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
76impl 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).
117pub 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.
126pub 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.
148pub 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).
153pub fn emoji_pack_len() -> usize {
154 twemoji_pack().len()
155}
156
157// ── Twemoji codepoint keys (matches twemoji.js grabTheRightIcon) ────────────
158
159const ZWJ: char = '\u{200D}';
160const VS16: char = '\u{FE0F}';
161const 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`).
167pub 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
182fn 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.
190fn 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.
247fn 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`.
262pub 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`]).
267pub 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.
280pub 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.
285pub 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`].
301pub 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`.
329pub 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).
337pub 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.
360pub 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.
376pub 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
469fn 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
495fn 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 ago508 for chunk in raw.as_chunks::<3>().0 {
Bring vidya in cfd3e36 nandi 19d ago509 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
521fn 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)]
530mod 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}