Skip to main content

ftui_widgets/
emoji.rs

1//! Emoji widget for rendering emoji characters with width-aware layout.
2//!
3//! Renders emoji text into a [`Frame`] respecting terminal width rules.
4//! Provides fallback behavior for unsupported or ambiguous-width emoji.
5//!
6//! # Example
7//!
8//! ```
9//! use ftui_widgets::emoji::Emoji;
10//!
11//! let emoji = Emoji::new("🎉");
12//! assert_eq!(emoji.text(), "🎉");
13//!
14//! let with_fallback = Emoji::new("🦀").with_fallback("[crab]");
15//! assert_eq!(with_fallback.fallback(), Some("[crab]"));
16//! ```
17
18use crate::{Widget, clear_text_row, draw_text_span};
19use ftui_core::geometry::Rect;
20use ftui_core::terminal_capabilities::TerminalCapabilities;
21use ftui_render::frame::Frame;
22use ftui_style::Style;
23use ftui_text::wrap::display_width;
24
25/// Widget for rendering emoji with width awareness and fallback.
26#[derive(Debug, Clone)]
27pub struct Emoji {
28    /// The emoji text to display.
29    text: String,
30    /// Fallback text for terminals that cannot render the emoji.
31    fallback: Option<String>,
32    /// Style applied to the emoji.
33    style: Style,
34    /// Style applied to fallback text.
35    fallback_style: Style,
36}
37
38impl Emoji {
39    /// Create a new emoji widget.
40    #[must_use]
41    pub fn new(text: impl Into<String>) -> Self {
42        Self {
43            text: text.into(),
44            fallback: None,
45            style: Style::default(),
46            fallback_style: Style::default(),
47        }
48    }
49
50    /// Set fallback text shown when emoji can't be rendered.
51    #[must_use]
52    pub fn with_fallback(mut self, fallback: impl Into<String>) -> Self {
53        self.fallback = Some(fallback.into());
54        self
55    }
56
57    /// Set the style for the emoji.
58    #[must_use]
59    pub fn with_style(mut self, style: Style) -> Self {
60        self.style = style;
61        self
62    }
63
64    /// Set the style for fallback text.
65    #[must_use]
66    pub fn with_fallback_style(mut self, style: Style) -> Self {
67        self.fallback_style = style;
68        self
69    }
70
71    /// Get the emoji text.
72    #[inline]
73    #[must_use]
74    pub fn text(&self) -> &str {
75        &self.text
76    }
77
78    /// Get the fallback text, if set.
79    #[inline]
80    #[must_use = "use the fallback text (if any)"]
81    pub fn fallback(&self) -> Option<&str> {
82        self.fallback.as_deref()
83    }
84
85    /// Compute the display width of the emoji.
86    #[inline]
87    #[must_use]
88    pub fn width(&self) -> usize {
89        display_width(&self.text)
90    }
91
92    /// Compute the display width of the fallback (or emoji if no fallback).
93    #[must_use]
94    pub fn effective_width(&self) -> usize {
95        match &self.fallback {
96            Some(fb) => display_width(fb),
97            None => self.width(),
98        }
99    }
100
101    /// Whether to use fallback based on emoji support.
102    #[must_use]
103    pub fn should_use_fallback(&self, use_emoji: bool) -> bool {
104        !use_emoji && self.fallback.is_some()
105    }
106}
107
108impl Widget for Emoji {
109    fn render(&self, area: Rect, frame: &mut Frame) {
110        if area.width == 0 || area.height == 0 {
111            return;
112        }
113
114        let deg = frame.buffer.degradation;
115        if !deg.render_content() {
116            return;
117        }
118
119        let max_x = area.right();
120        let use_fallback =
121            self.fallback.is_some() && !TerminalCapabilities::with_overrides().unicode_emoji;
122
123        if self.text.is_empty() {
124            clear_text_row(frame, area, Style::default());
125            return;
126        }
127
128        let (text, style) = if use_fallback {
129            let Some(text) = self.fallback.as_deref() else {
130                return;
131            };
132            let style = if deg.apply_styling() {
133                self.fallback_style
134            } else {
135                Style::default()
136            };
137            (text, style)
138        } else {
139            let style = if deg.apply_styling() {
140                self.style
141            } else {
142                Style::default()
143            };
144            (self.text.as_str(), style)
145        };
146
147        clear_text_row(frame, area, style);
148        draw_text_span(frame, area.x, area.y, text, style, max_x);
149    }
150
151    fn is_essential(&self) -> bool {
152        false
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use ftui_core::capability_override::{CapabilityOverride, with_capability_override};
160    use ftui_render::budget::DegradationLevel;
161    use ftui_render::cell::PackedRgba;
162    use ftui_render::frame::Frame;
163    use ftui_render::grapheme_pool::GraphemePool;
164
165    #[test]
166    fn new_emoji() {
167        let e = Emoji::new("🎉");
168        assert_eq!(e.text(), "🎉");
169        assert!(e.fallback().is_none());
170    }
171
172    #[test]
173    fn with_fallback() {
174        let e = Emoji::new("🦀").with_fallback("[crab]");
175        assert_eq!(e.fallback(), Some("[crab]"));
176    }
177
178    #[test]
179    fn width_measurement() {
180        let e = Emoji::new("🎉");
181        // Emoji typically 2 cells wide
182        assert!(e.width() > 0);
183    }
184
185    #[test]
186    fn effective_width_with_fallback() {
187        let e = Emoji::new("🦀").with_fallback("[crab]");
188        assert_eq!(e.effective_width(), 6); // "[crab]" = 6 chars
189    }
190
191    #[test]
192    fn effective_width_without_fallback() {
193        let e = Emoji::new("🎉");
194        assert_eq!(e.effective_width(), e.width());
195    }
196
197    #[test]
198    fn render_basic() {
199        let e = Emoji::new("A");
200        let mut pool = GraphemePool::new();
201        let mut frame = Frame::new(10, 1, &mut pool);
202        let area = Rect::new(0, 0, 10, 1);
203        e.render(area, &mut frame);
204
205        let cell = frame.buffer.get(0, 0).unwrap();
206        assert_eq!(cell.content.as_char(), Some('A'));
207    }
208
209    #[test]
210    fn render_zero_area() {
211        let e = Emoji::new("🎉");
212        let mut pool = GraphemePool::new();
213        let mut frame = Frame::new(10, 1, &mut pool);
214        e.render(Rect::new(0, 0, 0, 0), &mut frame); // No panic
215    }
216
217    #[test]
218    fn render_empty_text() {
219        let e = Emoji::new("");
220        let mut pool = GraphemePool::new();
221        let mut frame = Frame::new(10, 1, &mut pool);
222        e.render(Rect::new(0, 0, 10, 1), &mut frame); // No panic
223    }
224
225    #[test]
226    fn is_not_essential() {
227        let e = Emoji::new("🎉");
228        assert!(!e.is_essential());
229    }
230
231    #[test]
232    fn multi_char_emoji() {
233        let e = Emoji::new("👩‍💻");
234        assert!(e.width() > 0);
235    }
236
237    #[test]
238    fn text_as_emoji() {
239        // Simple text should work too
240        let e = Emoji::new("OK");
241        assert_eq!(e.width(), 2);
242    }
243
244    #[test]
245    fn should_use_fallback_logic() {
246        let e = Emoji::new("🎉").with_fallback("(party)");
247        assert!(e.should_use_fallback(false));
248        assert!(!e.should_use_fallback(true));
249    }
250
251    #[test]
252    fn should_not_use_fallback_without_setting() {
253        let e = Emoji::new("🎉");
254        assert!(!e.should_use_fallback(false));
255    }
256
257    #[test]
258    fn render_uses_fallback_when_unicode_emoji_disabled() {
259        with_capability_override(CapabilityOverride::new().unicode_emoji(Some(false)), || {
260            let e = Emoji::new("🦀").with_fallback("[crab]");
261            let mut pool = GraphemePool::new();
262            let mut frame = Frame::new(10, 1, &mut pool);
263            e.render(Rect::new(0, 0, 10, 1), &mut frame);
264
265            let cell = frame.buffer.get(0, 0).unwrap();
266            assert_eq!(cell.content.as_char(), Some('['));
267        });
268    }
269
270    #[test]
271    fn render_no_styling_keeps_emoji_when_supported() {
272        with_capability_override(CapabilityOverride::new().unicode_emoji(Some(true)), || {
273            let e = Emoji::new("🦀")
274                .with_fallback("[crab]")
275                .with_style(Style::new().fg(PackedRgba::rgb(1, 2, 3)));
276            let mut pool = GraphemePool::new();
277            let mut frame = Frame::new(10, 1, &mut pool);
278            frame.buffer.degradation = DegradationLevel::NoStyling;
279            e.render(Rect::new(0, 0, 10, 1), &mut frame);
280
281            let cell = frame.buffer.get(0, 0).unwrap();
282            let rendered_emoji = if let Some(ch) = cell.content.as_char() {
283                ch == '🦀'
284            } else if let Some(id) = cell.content.grapheme_id() {
285                frame.pool.get(id) == Some("🦀")
286            } else {
287                false
288            };
289            assert!(rendered_emoji, "expected emoji cell to contain 🦀");
290            assert_eq!(cell.fg, PackedRgba::WHITE);
291        });
292    }
293
294    #[test]
295    fn render_skeleton_is_noop() {
296        let e = Emoji::new("🦀").with_fallback("[crab]");
297        let mut pool = GraphemePool::new();
298        let mut frame = Frame::new(10, 1, &mut pool);
299        let mut expected_pool = GraphemePool::new();
300        let expected = Frame::new(10, 1, &mut expected_pool);
301        frame.buffer.degradation = DegradationLevel::Skeleton;
302        e.render(Rect::new(0, 0, 10, 1), &mut frame);
303
304        for x in 0..10 {
305            assert_eq!(frame.buffer.get(x, 0), expected.buffer.get(x, 0));
306        }
307    }
308
309    #[test]
310    fn render_shorter_text_clears_stale_suffix() {
311        let mut pool = GraphemePool::new();
312        let mut frame = Frame::new(6, 1, &mut pool);
313        let area = Rect::new(0, 0, 6, 1);
314
315        Emoji::new("OK").render(area, &mut frame);
316        Emoji::new("A").render(area, &mut frame);
317
318        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('A'));
319        assert_eq!(frame.buffer.get(1, 0).unwrap().content.as_char(), Some(' '));
320        assert_eq!(frame.buffer.get(2, 0).unwrap().content.as_char(), Some(' '));
321    }
322
323    #[test]
324    fn render_empty_text_clears_stale_row() {
325        let mut pool = GraphemePool::new();
326        let mut frame = Frame::new(6, 1, &mut pool);
327        let area = Rect::new(0, 0, 6, 1);
328
329        Emoji::new("OK").render(area, &mut frame);
330        Emoji::new("").render(area, &mut frame);
331
332        for x in 0..6u16 {
333            assert_eq!(frame.buffer.get(x, 0).unwrap().content.as_char(), Some(' '));
334        }
335    }
336}