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, draw_text_span};
19use ftui_core::geometry::Rect;
20use ftui_render::frame::Frame;
21use ftui_style::Style;
22use ftui_text::wrap::display_width;
23
24/// Widget for rendering emoji with width awareness and fallback.
25#[derive(Debug, Clone)]
26pub struct Emoji {
27    /// The emoji text to display.
28    text: String,
29    /// Fallback text for terminals that cannot render the emoji.
30    fallback: Option<String>,
31    /// Style applied to the emoji.
32    style: Style,
33    /// Style applied to fallback text.
34    fallback_style: Style,
35}
36
37impl Emoji {
38    /// Create a new emoji widget.
39    #[must_use]
40    pub fn new(text: impl Into<String>) -> Self {
41        Self {
42            text: text.into(),
43            fallback: None,
44            style: Style::default(),
45            fallback_style: Style::default(),
46        }
47    }
48
49    /// Set fallback text shown when emoji can't be rendered.
50    #[must_use]
51    pub fn with_fallback(mut self, fallback: impl Into<String>) -> Self {
52        self.fallback = Some(fallback.into());
53        self
54    }
55
56    /// Set the style for the emoji.
57    #[must_use]
58    pub fn with_style(mut self, style: Style) -> Self {
59        self.style = style;
60        self
61    }
62
63    /// Set the style for fallback text.
64    #[must_use]
65    pub fn with_fallback_style(mut self, style: Style) -> Self {
66        self.fallback_style = style;
67        self
68    }
69
70    /// Get the emoji text.
71    #[must_use]
72    pub fn text(&self) -> &str {
73        &self.text
74    }
75
76    /// Get the fallback text, if set.
77    #[must_use]
78    pub fn fallback(&self) -> Option<&str> {
79        self.fallback.as_deref()
80    }
81
82    /// Compute the display width of the emoji.
83    #[must_use]
84    pub fn width(&self) -> usize {
85        display_width(&self.text)
86    }
87
88    /// Compute the display width of the fallback (or emoji if no fallback).
89    #[must_use]
90    pub fn effective_width(&self) -> usize {
91        match &self.fallback {
92            Some(fb) => display_width(fb),
93            None => self.width(),
94        }
95    }
96
97    /// Whether to use fallback based on emoji support.
98    #[must_use]
99    pub fn should_use_fallback(&self, use_emoji: bool) -> bool {
100        !use_emoji && self.fallback.is_some()
101    }
102}
103
104impl Widget for Emoji {
105    fn render(&self, area: Rect, frame: &mut Frame) {
106        if area.width == 0 || area.height == 0 || self.text.is_empty() {
107            return;
108        }
109
110        let deg = frame.buffer.degradation;
111        let max_x = area.right();
112
113        // Use emoji directly if styling is available, otherwise try fallback
114        if deg.apply_styling() {
115            draw_text_span(frame, area.x, area.y, &self.text, self.style, max_x);
116        } else if let Some(fb) = &self.fallback {
117            draw_text_span(frame, area.x, area.y, fb, self.fallback_style, max_x);
118        } else {
119            draw_text_span(frame, area.x, area.y, &self.text, Style::default(), max_x);
120        }
121    }
122
123    fn is_essential(&self) -> bool {
124        false
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use ftui_render::frame::Frame;
132    use ftui_render::grapheme_pool::GraphemePool;
133
134    #[test]
135    fn new_emoji() {
136        let e = Emoji::new("🎉");
137        assert_eq!(e.text(), "🎉");
138        assert!(e.fallback().is_none());
139    }
140
141    #[test]
142    fn with_fallback() {
143        let e = Emoji::new("🦀").with_fallback("[crab]");
144        assert_eq!(e.fallback(), Some("[crab]"));
145    }
146
147    #[test]
148    fn width_measurement() {
149        let e = Emoji::new("🎉");
150        // Emoji typically 2 cells wide
151        assert!(e.width() > 0);
152    }
153
154    #[test]
155    fn effective_width_with_fallback() {
156        let e = Emoji::new("🦀").with_fallback("[crab]");
157        assert_eq!(e.effective_width(), 6); // "[crab]" = 6 chars
158    }
159
160    #[test]
161    fn effective_width_without_fallback() {
162        let e = Emoji::new("🎉");
163        assert_eq!(e.effective_width(), e.width());
164    }
165
166    #[test]
167    fn render_basic() {
168        let e = Emoji::new("A");
169        let mut pool = GraphemePool::new();
170        let mut frame = Frame::new(10, 1, &mut pool);
171        let area = Rect::new(0, 0, 10, 1);
172        e.render(area, &mut frame);
173
174        let cell = frame.buffer.get(0, 0).unwrap();
175        assert_eq!(cell.content.as_char(), Some('A'));
176    }
177
178    #[test]
179    fn render_zero_area() {
180        let e = Emoji::new("🎉");
181        let mut pool = GraphemePool::new();
182        let mut frame = Frame::new(10, 1, &mut pool);
183        e.render(Rect::new(0, 0, 0, 0), &mut frame); // No panic
184    }
185
186    #[test]
187    fn render_empty_text() {
188        let e = Emoji::new("");
189        let mut pool = GraphemePool::new();
190        let mut frame = Frame::new(10, 1, &mut pool);
191        e.render(Rect::new(0, 0, 10, 1), &mut frame); // No panic
192    }
193
194    #[test]
195    fn is_not_essential() {
196        let e = Emoji::new("🎉");
197        assert!(!e.is_essential());
198    }
199
200    #[test]
201    fn multi_char_emoji() {
202        let e = Emoji::new("👩‍💻");
203        assert!(e.width() > 0);
204    }
205
206    #[test]
207    fn text_as_emoji() {
208        // Simple text should work too
209        let e = Emoji::new("OK");
210        assert_eq!(e.width(), 2);
211    }
212
213    #[test]
214    fn should_use_fallback_logic() {
215        let e = Emoji::new("🎉").with_fallback("(party)");
216        assert!(e.should_use_fallback(false));
217        assert!(!e.should_use_fallback(true));
218    }
219
220    #[test]
221    fn should_not_use_fallback_without_setting() {
222        let e = Emoji::new("🎉");
223        assert!(!e.should_use_fallback(false));
224    }
225}