egui_twemoji/
lib.rs

1//! # egui-twemoji
2//!
3//! An [egui](https://egui.rs/) widget that renders colored [Twemojis](https://github.com/twitter/twemoji).
4//! Based on [twemoji-assets](https://github.com/cptpiepmatz/twemoji-assets).
5//!
6//! ![demo](https://github.com/zeozeozeo/egui-twemoji/blob/master/media/demo.png?raw=true)
7//!
8//! # How to use
9//!
10//! Make sure you've installed `egui_extras` image loaders (required for rendering SVG and PNG emotes):
11//!
12//! ```ignore
13//! // don't do this every frame - only when the app is created!
14//! egui_extras::install_image_loaders(&cc.egui_ctx);
15//! ```
16//!
17//! And then:
18//!
19//! ```rust
20//! use egui_twemoji::EmojiLabel;
21//!
22//! fn show_label(ui: &mut egui::Ui) {
23//!     EmojiLabel::new("⭐ egui-twemoji 🐦✨").show(ui);
24//! }
25//! ```
26//!
27//! For a more sophisticated example, see the `demo` example (`cargo run --example demo`)
28//!
29//! `EmojiLabel` supports all functions that a normal
30//! [Label](https://docs.rs/egui/latest/egui/widgets/struct.Label.html) does.
31//!
32//! # Features
33//!
34//! * `svg`: use SVG emoji assets (`egui_extras/svg` is required)
35//! * `png`: use PNG emoji assets (`egui_extras/image` is required)
36//!
37//! By default, the `svg` feature is activated.
38//!
39//! # License
40//!
41//! Unlicense OR MIT OR Apache-2.0
42
43#![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/// Represents a segment of text which can be either plain text or an emoji.
55///
56/// * `Text` variant wraps the `RichText` struct, which includes text and its styling information.
57/// * `Emoji` variant contains a `String` representing the emoji character.
58#[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
73/// Returns a vector of [`TextSegment`]s from a [`RichText`], segmented by emojis.
74///
75/// ## Example:
76///
77/// "hello 😤 world" -> `[TextSegment::Text("hello "), TextSegment::Emoji("😤"), TextSegment::Text(" world")]`
78fn 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/// The state of an [EmojiLabel], stored in egui's [`egui::Memory`].
106/// This includes memoized text segments and whether the state was newly created.
107#[derive(Default, Clone)]
108struct LabelState {
109    segments: Vec<TextSegment>,
110    is_saved: bool,
111}
112
113impl LabelState {
114    /// Create a new state from a [`RichText`], segmenting it by emojis.
115    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    /// Load the state from egui's [`egui::Memory`].
124    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    /// Save the state to egui's [`egui::Memory`]. Only call this if [`Self::is_saved`] is `false`.
132    fn save(self, ctx: &egui::Context, id: egui::Id) {
133        ctx.data_mut(|d| d.insert_temp(id, self));
134    }
135}
136
137/// An [egui](https://egui.rs/) widget that renders colored [Twemojis](https://github.com/twitter/twemoji).
138///
139/// ```rust
140/// use egui_twemoji::EmojiLabel;
141///
142/// fn show_label(ui: &mut egui::Ui) {
143///     EmojiLabel::new("⭐ egui-twemoji 🐦✨").show(ui);
144/// }
145/// ```
146#[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    /// Create a new [`EmojiLabel`] from a [`RichText`].
194    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    /// Get the text to render as a [str].
205    pub fn text(&self) -> &str {
206        self.text.text()
207    }
208
209    /// Get the text to render as a [`RichText`].
210    pub fn rich_text(&self) -> &RichText {
211        &self.text
212    }
213
214    /// Set the wrap mode for the text.
215    ///
216    /// By default, [`egui::Ui::wrap_mode`] will be used, which can be overridden with [`egui::Style::wrap_mode`].
217    ///
218    /// Note that any `\n` in the text will always produce a new line.
219    #[inline]
220    pub fn wrap_mode(mut self, wrap_mode: TextWrapMode) -> Self {
221        self.wrap_mode = Some(wrap_mode);
222        self
223    }
224
225    /// Set [`Self::wrap_mode`] to [`TextWrapMode::Wrap`].
226    #[inline]
227    pub fn wrap(mut self) -> Self {
228        self.wrap_mode = Some(TextWrapMode::Wrap);
229        self
230    }
231
232    /// Set [`Self::wrap_mode`] to [`TextWrapMode::Truncate`].
233    #[inline]
234    pub fn truncate(mut self) -> Self {
235        self.wrap_mode = Some(TextWrapMode::Truncate);
236        self
237    }
238
239    /// Set [`Self::wrap_mode`] to [`TextWrapMode::Extend`],
240    /// disabling wrapping and truncating, and instead expanding the parent [`Ui`].
241    #[inline]
242    pub fn extend(mut self) -> Self {
243        self.wrap_mode = Some(TextWrapMode::Extend);
244        self
245    }
246
247    /// Can the user select the text with the mouse?
248    ///
249    /// Overrides [`egui::style::Interaction::selectable_labels`].
250    #[inline]
251    pub fn selectable(mut self, selectable: bool) -> Self {
252        self.selectable = Some(selectable);
253        self
254    }
255
256    /// Make the label respond to clicks and/or drags.
257    ///
258    /// By default, a label is inert and does not respond to click or drags.
259    /// By calling this you can turn the label into a button of sorts.
260    /// This will also give the label the hover-effect of a button, but without the frame.
261    #[inline]
262    pub fn sense(mut self, sense: Sense) -> Self {
263        self.sense = Some(sense);
264        self
265    }
266
267    /// Whether the widget should recognize that it is in a horizontal layout and not create a new one.
268    /// This fixes some wrapping issues with [`egui::Label`].
269    ///
270    /// In vertical layouts, the widget will create a new horizontal layout so text segments stay on the
271    /// same line.
272    #[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                    // for emoji selection and copying:
307                    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    /// Add the label to an [`egui::Ui`].
320    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 the state was newly created, write it back to memory:
325        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}