1use 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#[derive(Debug, Clone)]
27pub struct Emoji {
28 text: String,
30 fallback: Option<String>,
32 style: Style,
34 fallback_style: Style,
36}
37
38impl Emoji {
39 #[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 #[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 #[must_use]
59 pub fn with_style(mut self, style: Style) -> Self {
60 self.style = style;
61 self
62 }
63
64 #[must_use]
66 pub fn with_fallback_style(mut self, style: Style) -> Self {
67 self.fallback_style = style;
68 self
69 }
70
71 #[inline]
73 #[must_use]
74 pub fn text(&self) -> &str {
75 &self.text
76 }
77
78 #[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 #[inline]
87 #[must_use]
88 pub fn width(&self) -> usize {
89 display_width(&self.text)
90 }
91
92 #[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 #[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 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); }
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); }
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); }
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 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}