1#![warn(missing_docs)]
44
45mod exposed;
46
47use egui::{ImageSource, Layout, RichText, Sense, TextWrapMode};
48use exposed::ExposedRichText;
49use unicode_segmentation::UnicodeSegmentation;
50
51#[cfg(all(feature = "svg", feature = "png"))]
52compile_error!("features 'svg' and 'png' are mutually exclusive and cannot be enabled together");
53
54#[derive(PartialEq, Clone)]
59enum TextSegment {
60 Text(RichText),
61 Emoji(String),
62}
63
64#[inline]
65fn is_emoji(text: &str) -> bool {
66 #[cfg(feature = "svg")]
67 return twemoji_assets::svg::SvgTwemojiAsset::from_emoji(text).is_some();
68
69 #[cfg(feature = "png")]
70 return twemoji_assets::png::PngTwemojiAsset::from_emoji(text).is_some();
71}
72
73fn segment_text(input: &RichText) -> Vec<TextSegment> {
79 let mut result = Vec::new();
80 let mut text = String::new();
81
82 for grapheme in UnicodeSegmentation::graphemes(input.text(), true) {
83 if is_emoji(grapheme) {
84 if !text.is_empty() {
85 result.push(TextSegment::Text(
86 ExposedRichText::new_keep_properties(text.clone(), input).into(),
87 ));
88 text.clear();
89 }
90 result.push(TextSegment::Emoji(grapheme.to_string()));
91 } else {
92 text.push_str(grapheme);
93 }
94 }
95
96 if !text.is_empty() {
97 result.push(TextSegment::Text(
98 ExposedRichText::new_keep_properties(text.clone(), input).into(),
99 ));
100 }
101
102 result
103}
104
105#[derive(Default, Clone)]
108struct LabelState {
109 segments: Vec<TextSegment>,
110 is_saved: bool,
111}
112
113impl LabelState {
114 fn from_text(text: impl Into<RichText>) -> Self {
116 let rich_text = text.into();
117 Self {
118 segments: segment_text(&rich_text),
119 is_saved: false,
120 }
121 }
122
123 fn load(ctx: &egui::Context, id: egui::Id, text: &RichText) -> Self {
125 ctx.data_mut(|d| {
126 d.get_temp(id)
127 .unwrap_or_else(|| Self::from_text(text.clone()))
128 })
129 }
130
131 fn save(self, ctx: &egui::Context, id: egui::Id) {
133 ctx.data_mut(|d| d.insert_temp(id, self));
134 }
135}
136
137#[must_use = "You should put this widget in an ui by calling `.show(ui);`"]
147pub struct EmojiLabel {
148 text: RichText,
149 wrap_mode: Option<TextWrapMode>,
150 sense: Option<Sense>,
151 selectable: Option<bool>,
152 auto_inline: bool,
153}
154
155fn get_source_for_emoji(emoji: &str) -> Option<ImageSource<'_>> {
156 #[cfg(feature = "svg")]
157 {
158 let svg_data = twemoji_assets::svg::SvgTwemojiAsset::from_emoji(emoji)?;
159 let source = ImageSource::Bytes {
160 uri: format!("{emoji}.svg").into(),
161 bytes: egui::load::Bytes::Static(svg_data.as_bytes()),
162 };
163 Some(source)
164 }
165
166 #[cfg(feature = "png")]
167 {
168 let png_data: &[u8] = twemoji_assets::png::PngTwemojiAsset::from_emoji(emoji)?;
169 let source = ImageSource::Bytes {
170 uri: format!("{emoji}.png").into(),
171 bytes: egui::load::Bytes::Static(png_data),
172 };
173 Some(source)
174 }
175}
176
177#[inline]
178fn empty_response(ctx: egui::Context) -> egui::Response {
179 egui::Response {
180 ctx,
181 layer_id: egui::LayerId::background(),
182 id: egui::Id::NULL,
183 rect: egui::Rect::ZERO,
184 interact_rect: egui::Rect::ZERO,
185 sense: Sense::click(),
186 flags: egui::response::Flags::empty(),
187 interact_pointer_pos: None,
188 intrinsic_size: None,
189 }
190}
191
192impl EmojiLabel {
193 pub fn new(text: impl Into<RichText>) -> Self {
195 Self {
196 text: text.into(),
197 wrap_mode: None,
198 sense: None,
199 selectable: None,
200 auto_inline: true,
201 }
202 }
203
204 pub fn text(&self) -> &str {
206 self.text.text()
207 }
208
209 pub fn rich_text(&self) -> &RichText {
211 &self.text
212 }
213
214 #[inline]
220 pub fn wrap_mode(mut self, wrap_mode: TextWrapMode) -> Self {
221 self.wrap_mode = Some(wrap_mode);
222 self
223 }
224
225 #[inline]
227 pub fn wrap(mut self) -> Self {
228 self.wrap_mode = Some(TextWrapMode::Wrap);
229 self
230 }
231
232 #[inline]
234 pub fn truncate(mut self) -> Self {
235 self.wrap_mode = Some(TextWrapMode::Truncate);
236 self
237 }
238
239 #[inline]
242 pub fn extend(mut self) -> Self {
243 self.wrap_mode = Some(TextWrapMode::Extend);
244 self
245 }
246
247 #[inline]
251 pub fn selectable(mut self, selectable: bool) -> Self {
252 self.selectable = Some(selectable);
253 self
254 }
255
256 #[inline]
262 pub fn sense(mut self, sense: Sense) -> Self {
263 self.sense = Some(sense);
264 self
265 }
266
267 #[inline]
273 pub fn auto_inline(mut self, auto_inline: bool) -> Self {
274 self.auto_inline = auto_inline;
275 self
276 }
277
278 fn show_segments(&self, ui: &mut egui::Ui, state: &mut LabelState) -> egui::Response {
279 let mut resp = empty_response(ui.ctx().clone());
280 let font_height = ui.text_style_height(&egui::TextStyle::Body);
281
282 for segment in &state.segments {
283 ui.spacing_mut().item_spacing.x = 0.0;
284 match segment {
285 TextSegment::Text(text) => {
286 let mut label = egui::Label::new(text.clone());
287 if let Some(wrap_mode) = self.wrap_mode {
288 label = label.wrap_mode(wrap_mode);
289 }
290 let label = ui.add(label);
291 resp.layer_id = label.layer_id;
292 resp |= label;
293 }
294 TextSegment::Emoji(emoji) => {
295 let Some(source) = get_source_for_emoji(emoji) else {
296 continue;
297 };
298
299 let image_rect = ui
300 .add(
301 egui::Image::new(source)
302 .fit_to_exact_size(egui::vec2(font_height, font_height)),
303 )
304 .rect;
305
306 let emoji = ui.put(
308 image_rect,
309 egui::Label::new(RichText::new(emoji).color(egui::Color32::TRANSPARENT)),
310 );
311 resp.layer_id = emoji.layer_id;
312 resp |= emoji;
313 }
314 }
315 }
316 resp
317 }
318
319 pub fn show(self, ui: &mut egui::Ui) -> egui::Response {
321 let id = egui::Id::new(self.text());
322 let mut state = LabelState::load(ui.ctx(), id, &self.text);
323
324 if !state.is_saved {
326 state.is_saved = true;
327 state.clone().save(ui.ctx(), id);
328 }
329
330 if ui.layout().is_horizontal() && self.auto_inline {
331 self.show_segments(ui, &mut state)
332 } else {
333 ui.with_layout(Layout::left_to_right(egui::Align::Min), |ui| {
334 self.show_segments(ui, &mut state)
335 })
336 .inner
337 }
338 }
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344
345 #[test]
346 fn emoji_segmentation() {
347 let text = "Hello😤world";
348 let segments = segment_text(&RichText::new(text));
349 assert!(
350 segments
351 == vec![
352 TextSegment::Text("Hello".into()),
353 TextSegment::Emoji("😤".to_owned()),
354 TextSegment::Text("world".into())
355 ]
356 );
357 let text = "😅 2,*:привет|3 🤬";
358 let segments = segment_text(&RichText::new(text));
359 assert!(
360 segments
361 == vec![
362 TextSegment::Emoji("😅".to_owned()),
363 TextSegment::Text(" 2,*:привет|3 ".into()),
364 TextSegment::Emoji("🤬".to_owned()),
365 ]
366 );
367 let text = "Hello world 🥰!";
368 let segments = segment_text(&RichText::new(text));
369 assert!(
370 segments
371 == vec![
372 TextSegment::Text("Hello world ".into()),
373 TextSegment::Emoji("🥰".to_owned()),
374 TextSegment::Text("!".into())
375 ]
376 );
377 }
378}