use crate::{Widget, clear_text_row, draw_text_span};
use ftui_core::geometry::Rect;
use ftui_core::terminal_capabilities::TerminalCapabilities;
use ftui_render::frame::Frame;
use ftui_style::Style;
use ftui_text::wrap::display_width;
#[derive(Debug, Clone)]
pub struct Emoji {
text: String,
fallback: Option<String>,
style: Style,
fallback_style: Style,
}
impl Emoji {
#[must_use]
pub fn new(text: impl Into<String>) -> Self {
Self {
text: text.into(),
fallback: None,
style: Style::default(),
fallback_style: Style::default(),
}
}
#[must_use]
pub fn with_fallback(mut self, fallback: impl Into<String>) -> Self {
self.fallback = Some(fallback.into());
self
}
#[must_use]
pub fn with_style(mut self, style: Style) -> Self {
self.style = style;
self
}
#[must_use]
pub fn with_fallback_style(mut self, style: Style) -> Self {
self.fallback_style = style;
self
}
#[inline]
#[must_use]
pub fn text(&self) -> &str {
&self.text
}
#[inline]
#[must_use = "use the fallback text (if any)"]
pub fn fallback(&self) -> Option<&str> {
self.fallback.as_deref()
}
#[inline]
#[must_use]
pub fn width(&self) -> usize {
display_width(&self.text)
}
#[must_use]
pub fn effective_width(&self) -> usize {
match &self.fallback {
Some(fb) => display_width(fb),
None => self.width(),
}
}
#[must_use]
pub fn should_use_fallback(&self, use_emoji: bool) -> bool {
!use_emoji && self.fallback.is_some()
}
}
impl Widget for Emoji {
fn render(&self, area: Rect, frame: &mut Frame) {
if area.width == 0 || area.height == 0 {
return;
}
let deg = frame.buffer.degradation;
if !deg.render_content() {
return;
}
let max_x = area.right();
let use_fallback =
self.fallback.is_some() && !TerminalCapabilities::with_overrides().unicode_emoji;
if self.text.is_empty() {
clear_text_row(frame, area, Style::default());
return;
}
let (text, style) = if use_fallback {
let Some(text) = self.fallback.as_deref() else {
return;
};
let style = if deg.apply_styling() {
self.fallback_style
} else {
Style::default()
};
(text, style)
} else {
let style = if deg.apply_styling() {
self.style
} else {
Style::default()
};
(self.text.as_str(), style)
};
clear_text_row(frame, area, style);
draw_text_span(frame, area.x, area.y, text, style, max_x);
}
fn is_essential(&self) -> bool {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
use ftui_core::capability_override::{CapabilityOverride, with_capability_override};
use ftui_render::budget::DegradationLevel;
use ftui_render::cell::PackedRgba;
use ftui_render::frame::Frame;
use ftui_render::grapheme_pool::GraphemePool;
#[test]
fn new_emoji() {
let e = Emoji::new("🎉");
assert_eq!(e.text(), "🎉");
assert!(e.fallback().is_none());
}
#[test]
fn with_fallback() {
let e = Emoji::new("🦀").with_fallback("[crab]");
assert_eq!(e.fallback(), Some("[crab]"));
}
#[test]
fn width_measurement() {
let e = Emoji::new("🎉");
assert!(e.width() > 0);
}
#[test]
fn effective_width_with_fallback() {
let e = Emoji::new("🦀").with_fallback("[crab]");
assert_eq!(e.effective_width(), 6); }
#[test]
fn effective_width_without_fallback() {
let e = Emoji::new("🎉");
assert_eq!(e.effective_width(), e.width());
}
#[test]
fn render_basic() {
let e = Emoji::new("A");
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 1, &mut pool);
let area = Rect::new(0, 0, 10, 1);
e.render(area, &mut frame);
let cell = frame.buffer.get(0, 0).unwrap();
assert_eq!(cell.content.as_char(), Some('A'));
}
#[test]
fn render_zero_area() {
let e = Emoji::new("🎉");
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 1, &mut pool);
e.render(Rect::new(0, 0, 0, 0), &mut frame); }
#[test]
fn render_empty_text() {
let e = Emoji::new("");
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 1, &mut pool);
e.render(Rect::new(0, 0, 10, 1), &mut frame); }
#[test]
fn is_not_essential() {
let e = Emoji::new("🎉");
assert!(!e.is_essential());
}
#[test]
fn multi_char_emoji() {
let e = Emoji::new("👩💻");
assert!(e.width() > 0);
}
#[test]
fn text_as_emoji() {
let e = Emoji::new("OK");
assert_eq!(e.width(), 2);
}
#[test]
fn should_use_fallback_logic() {
let e = Emoji::new("🎉").with_fallback("(party)");
assert!(e.should_use_fallback(false));
assert!(!e.should_use_fallback(true));
}
#[test]
fn should_not_use_fallback_without_setting() {
let e = Emoji::new("🎉");
assert!(!e.should_use_fallback(false));
}
#[test]
fn render_uses_fallback_when_unicode_emoji_disabled() {
with_capability_override(CapabilityOverride::new().unicode_emoji(Some(false)), || {
let e = Emoji::new("🦀").with_fallback("[crab]");
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 1, &mut pool);
e.render(Rect::new(0, 0, 10, 1), &mut frame);
let cell = frame.buffer.get(0, 0).unwrap();
assert_eq!(cell.content.as_char(), Some('['));
});
}
#[test]
fn render_no_styling_keeps_emoji_when_supported() {
with_capability_override(CapabilityOverride::new().unicode_emoji(Some(true)), || {
let e = Emoji::new("🦀")
.with_fallback("[crab]")
.with_style(Style::new().fg(PackedRgba::rgb(1, 2, 3)));
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 1, &mut pool);
frame.buffer.degradation = DegradationLevel::NoStyling;
e.render(Rect::new(0, 0, 10, 1), &mut frame);
let cell = frame.buffer.get(0, 0).unwrap();
let rendered_emoji = if let Some(ch) = cell.content.as_char() {
ch == '🦀'
} else if let Some(id) = cell.content.grapheme_id() {
frame.pool.get(id) == Some("🦀")
} else {
false
};
assert!(rendered_emoji, "expected emoji cell to contain 🦀");
assert_eq!(cell.fg, PackedRgba::WHITE);
});
}
#[test]
fn render_skeleton_is_noop() {
let e = Emoji::new("🦀").with_fallback("[crab]");
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 1, &mut pool);
let mut expected_pool = GraphemePool::new();
let expected = Frame::new(10, 1, &mut expected_pool);
frame.buffer.degradation = DegradationLevel::Skeleton;
e.render(Rect::new(0, 0, 10, 1), &mut frame);
for x in 0..10 {
assert_eq!(frame.buffer.get(x, 0), expected.buffer.get(x, 0));
}
}
#[test]
fn render_shorter_text_clears_stale_suffix() {
let mut pool = GraphemePool::new();
let mut frame = Frame::new(6, 1, &mut pool);
let area = Rect::new(0, 0, 6, 1);
Emoji::new("OK").render(area, &mut frame);
Emoji::new("A").render(area, &mut frame);
assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('A'));
assert_eq!(frame.buffer.get(1, 0).unwrap().content.as_char(), Some(' '));
assert_eq!(frame.buffer.get(2, 0).unwrap().content.as_char(), Some(' '));
}
#[test]
fn render_empty_text_clears_stale_row() {
let mut pool = GraphemePool::new();
let mut frame = Frame::new(6, 1, &mut pool);
let area = Rect::new(0, 0, 6, 1);
Emoji::new("OK").render(area, &mut frame);
Emoji::new("").render(area, &mut frame);
for x in 0..6u16 {
assert_eq!(frame.buffer.get(x, 0).unwrap().content.as_char(), Some(' '));
}
}
}