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    #[inline]
72    #[must_use]
73    pub fn text(&self) -> &str {
74        &self.text
75    }
76
77    /// Get the fallback text, if set.
78    #[inline]
79    #[must_use = "use the fallback text (if any)"]
80    pub fn fallback(&self) -> Option<&str> {
81        self.fallback.as_deref()
82    }
83
84    /// Compute the display width of the emoji.
85    #[inline]
86    #[must_use]
87    pub fn width(&self) -> usize {
88        display_width(&self.text)
89    }
90
91    /// Compute the display width of the fallback (or emoji if no fallback).
92    #[must_use]
93    pub fn effective_width(&self) -> usize {
94        match &self.fallback {
95            Some(fb) => display_width(fb),
96            None => self.width(),
97        }
98    }
99
100    /// Whether to use fallback based on emoji support.
101    #[must_use]
102    pub fn should_use_fallback(&self, use_emoji: bool) -> bool {
103        !use_emoji && self.fallback.is_some()
104    }
105}
106
107impl Widget for Emoji {
108    fn render(&self, area: Rect, frame: &mut Frame) {
109        if area.width == 0 || area.height == 0 || self.text.is_empty() {
110            return;
111        }
112
113        let deg = frame.buffer.degradation;
114        let max_x = area.right();
115
116        // Use emoji directly if styling is available, otherwise try fallback
117        if deg.apply_styling() {
118            draw_text_span(frame, area.x, area.y, &self.text, self.style, max_x);
119        } else if let Some(fb) = &self.fallback {
120            draw_text_span(frame, area.x, area.y, fb, self.fallback_style, max_x);
121        } else {
122            draw_text_span(frame, area.x, area.y, &self.text, Style::default(), max_x);
123        }
124    }
125
126    fn is_essential(&self) -> bool {
127        false
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use ftui_render::frame::Frame;
135    use ftui_render::grapheme_pool::GraphemePool;
136
137    #[test]
138    fn new_emoji() {
139        let e = Emoji::new("🎉");
140        assert_eq!(e.text(), "🎉");
141        assert!(e.fallback().is_none());
142    }
143
144    #[test]
145    fn with_fallback() {
146        let e = Emoji::new("🦀").with_fallback("[crab]");
147        assert_eq!(e.fallback(), Some("[crab]"));
148    }
149
150    #[test]
151    fn width_measurement() {
152        let e = Emoji::new("🎉");
153        // Emoji typically 2 cells wide
154        assert!(e.width() > 0);
155    }
156
157    #[test]
158    fn effective_width_with_fallback() {
159        let e = Emoji::new("🦀").with_fallback("[crab]");
160        assert_eq!(e.effective_width(), 6); // "[crab]" = 6 chars
161    }
162
163    #[test]
164    fn effective_width_without_fallback() {
165        let e = Emoji::new("🎉");
166        assert_eq!(e.effective_width(), e.width());
167    }
168
169    #[test]
170    fn render_basic() {
171        let e = Emoji::new("A");
172        let mut pool = GraphemePool::new();
173        let mut frame = Frame::new(10, 1, &mut pool);
174        let area = Rect::new(0, 0, 10, 1);
175        e.render(area, &mut frame);
176
177        let cell = frame.buffer.get(0, 0).unwrap();
178        assert_eq!(cell.content.as_char(), Some('A'));
179    }
180
181    #[test]
182    fn render_zero_area() {
183        let e = Emoji::new("🎉");
184        let mut pool = GraphemePool::new();
185        let mut frame = Frame::new(10, 1, &mut pool);
186        e.render(Rect::new(0, 0, 0, 0), &mut frame); // No panic
187    }
188
189    #[test]
190    fn render_empty_text() {
191        let e = Emoji::new("");
192        let mut pool = GraphemePool::new();
193        let mut frame = Frame::new(10, 1, &mut pool);
194        e.render(Rect::new(0, 0, 10, 1), &mut frame); // No panic
195    }
196
197    #[test]
198    fn is_not_essential() {
199        let e = Emoji::new("🎉");
200        assert!(!e.is_essential());
201    }
202
203    #[test]
204    fn multi_char_emoji() {
205        let e = Emoji::new("👩‍💻");
206        assert!(e.width() > 0);
207    }
208
209    #[test]
210    fn text_as_emoji() {
211        // Simple text should work too
212        let e = Emoji::new("OK");
213        assert_eq!(e.width(), 2);
214    }
215
216    #[test]
217    fn should_use_fallback_logic() {
218        let e = Emoji::new("🎉").with_fallback("(party)");
219        assert!(e.should_use_fallback(false));
220        assert!(!e.should_use_fallback(true));
221    }
222
223    #[test]
224    fn should_not_use_fallback_without_setting() {
225        let e = Emoji::new("🎉");
226        assert!(!e.should_use_fallback(false));
227    }
228}