azalea_chat/
text_component.rs

1use std::fmt::{self, Display};
2
3use serde::{Serialize, Serializer, ser::SerializeMap};
4
5use crate::{
6    FormattedText,
7    base_component::BaseComponent,
8    style::{ChatFormatting, Style, TextColor},
9};
10
11/// A component that contains text that's the same in all locales.
12#[derive(Clone, Debug, Default, PartialEq)]
13pub struct TextComponent {
14    pub base: BaseComponent,
15    pub text: String,
16}
17impl Serialize for TextComponent {
18    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
19    where
20        S: Serializer,
21    {
22        if self.base == BaseComponent::default() {
23            return serializer.serialize_str(&self.text);
24        }
25
26        let mut state = serializer.serialize_map(None)?;
27        state.serialize_entry("text", &self.text)?;
28
29        self.base.serialize_map::<S>(&mut state)?;
30
31        if !self.base.siblings.is_empty() {
32            state.serialize_entry("extra", &self.base.siblings)?;
33        }
34        state.end()
35    }
36}
37
38#[cfg(feature = "simdnbt")]
39impl simdnbt::Serialize for TextComponent {
40    fn to_compound(self) -> simdnbt::owned::NbtCompound {
41        let mut compound = simdnbt::owned::NbtCompound::new();
42        compound.insert("text", self.text);
43        compound.extend(self.base.style.to_compound());
44        if !self.base.siblings.is_empty() {
45            compound.insert(
46                "extra",
47                simdnbt::owned::NbtList::from(
48                    self.base
49                        .siblings
50                        .into_iter()
51                        .map(|component| component.to_compound())
52                        .collect::<Vec<_>>(),
53                ),
54            );
55        }
56        compound
57    }
58}
59
60const LEGACY_FORMATTING_CODE_SYMBOL: char = '§';
61
62/// Convert a legacy color code string into a FormattedText
63/// Technically in Minecraft this is done when displaying the text, but AFAIK
64/// it's the same as just doing it in TextComponent
65pub fn legacy_color_code_to_text_component(legacy_color_code: &str) -> TextComponent {
66    if legacy_color_code.is_empty() {
67        return TextComponent::new("");
68    }
69
70    let mut components: Vec<TextComponent> = Vec::with_capacity(1);
71    // iterate over legacy_color_code, if it starts with LEGACY_COLOR_CODE_SYMBOL
72    // then read the next character and get the style from that otherwise, add
73    // the character to the text
74
75    let mut cur_component = TextComponent::new("");
76
77    // we don't use a normal for loop since we need to be able to skip after reading
78    // the formatter code symbol
79    let mut i = 0;
80    while i < legacy_color_code.chars().count() {
81        if legacy_color_code.chars().nth(i).unwrap() == LEGACY_FORMATTING_CODE_SYMBOL {
82            let formatting_code = legacy_color_code.chars().nth(i + 1);
83            let Some(formatting_code) = formatting_code else {
84                i += 1;
85                continue;
86            };
87            if formatting_code == '#' {
88                let color = legacy_color_code
89                    .chars()
90                    .skip(i + 1)
91                    // 7 to include the #
92                    .take(7)
93                    .collect::<String>();
94
95                if !cur_component.text.is_empty() {
96                    // we need to split this into a new component
97                    components.push(cur_component.clone());
98                    cur_component.text = "".to_string();
99                };
100                cur_component.base.style.color = TextColor::parse(&color);
101
102                i += 6;
103            } else if let Some(formatter) = ChatFormatting::from_code(formatting_code) {
104                if !cur_component.text.is_empty() || formatter == ChatFormatting::Reset {
105                    // we need to split this into a new component
106                    components.push(cur_component.clone());
107                    cur_component.text = "".to_string();
108                };
109                cur_component.base.style.apply_formatting(&formatter);
110            }
111            i += 1;
112        } else {
113            cur_component
114                .text
115                .push(legacy_color_code.chars().nth(i).unwrap());
116        };
117        i += 1;
118    }
119
120    components.push(cur_component);
121
122    // create the final component by adding all of the components as siblings
123    let mut final_component = TextComponent::new("");
124    for component in components {
125        final_component.base.siblings.push(component.get());
126    }
127
128    final_component
129}
130
131impl TextComponent {
132    pub fn new(text: impl Into<String>) -> Self {
133        let text = text.into();
134        // if it contains a LEGACY_FORMATTING_CODE_SYMBOL, format it
135        if text.contains(LEGACY_FORMATTING_CODE_SYMBOL) {
136            legacy_color_code_to_text_component(&text)
137        } else {
138            Self {
139                base: BaseComponent::new(),
140                text,
141            }
142        }
143    }
144
145    fn get(self) -> FormattedText {
146        FormattedText::Text(self)
147    }
148    pub fn with_style(mut self, style: Style) -> Self {
149        self.base.style = Box::new(style);
150        self
151    }
152}
153
154impl Display for TextComponent {
155    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156        // this contains the final string will all the ansi escape codes
157        for component in FormattedText::Text(self.clone()).into_iter() {
158            let component_text = match &component {
159                FormattedText::Text(c) => c.text.to_string(),
160                FormattedText::Translatable(c) => c.read()?.to_string(),
161            };
162
163            f.write_str(&component_text)?;
164        }
165
166        Ok(())
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use crate::style::Ansi;
174
175    #[test]
176    fn test_hypixel_motd_ansi() {
177        let component =
178            TextComponent::new("§aHypixel Network  §c[1.8-1.18]\n§b§lHAPPY HOLIDAYS".to_string())
179                .get();
180        assert_eq!(
181            component.to_ansi(),
182            format!(
183                "{GREEN}Hypixel Network  {RED}[1.8-1.18]\n{BOLD}{AQUA}HAPPY HOLIDAYS{RESET}",
184                GREEN = Ansi::rgb(ChatFormatting::Green.color().unwrap()),
185                RED = Ansi::rgb(ChatFormatting::Red.color().unwrap()),
186                AQUA = Ansi::rgb(ChatFormatting::Aqua.color().unwrap()),
187                BOLD = Ansi::BOLD,
188                RESET = Ansi::RESET
189            )
190        );
191    }
192
193    #[test]
194    fn test_hypixel_motd_html() {
195        let component =
196            TextComponent::new("§aHypixel Network  §c[1.8-1.18]\n§b§lHAPPY HOLIDAYS".to_string())
197                .get();
198
199        assert_eq!(
200            component.to_html(),
201            format!(
202                "{GREEN}Hypixel Network  {END_SPAN}{RED}[1.8-1.18]<br>{END_SPAN}{BOLD_AQUA}HAPPY HOLIDAYS{END_SPAN}",
203                END_SPAN = "</span>",
204                GREEN = "<span style=\"color:#55FF55;\">",
205                RED = "<span style=\"color:#FF5555;\">",
206                BOLD_AQUA = "<span style=\"color:#55FFFF;font-weight:bold;\">",
207            )
208        );
209    }
210
211    #[test]
212    fn test_xss_html() {
213        let component = TextComponent::new("§a<b>&\n§b</b>".to_string()).get();
214
215        assert_eq!(
216            component.to_html(),
217            format!(
218                "{GREEN}&lt;b&gt;&amp;<br>{END_SPAN}{AQUA}&lt;/b&gt;{END_SPAN}",
219                END_SPAN = "</span>",
220                GREEN = "<span style=\"color:#55FF55;\">",
221                AQUA = "<span style=\"color:#55FFFF;\">",
222            )
223        );
224    }
225
226    #[test]
227    fn test_legacy_color_code_to_component() {
228        let component = TextComponent::new("§lHello §r§1w§2o§3r§4l§5d".to_string()).get();
229        assert_eq!(
230            component.to_ansi(),
231            format!(
232                "{BOLD}{WHITE}Hello {RESET}{DARK_BLUE}w{DARK_GREEN}o{DARK_AQUA}r{DARK_RED}l{DARK_PURPLE}d{RESET}",
233                BOLD = Ansi::BOLD,
234                WHITE = Ansi::rgb(ChatFormatting::White.color().unwrap()),
235                RESET = Ansi::RESET,
236                DARK_BLUE = Ansi::rgb(ChatFormatting::DarkBlue.color().unwrap()),
237                DARK_GREEN = Ansi::rgb(ChatFormatting::DarkGreen.color().unwrap()),
238                DARK_AQUA = Ansi::rgb(ChatFormatting::DarkAqua.color().unwrap()),
239                DARK_RED = Ansi::rgb(ChatFormatting::DarkRed.color().unwrap()),
240                DARK_PURPLE = Ansi::rgb(ChatFormatting::DarkPurple.color().unwrap())
241            )
242        );
243    }
244
245    #[test]
246    fn test_legacy_color_code_with_rgb() {
247        let component = TextComponent::new("§#Ff0000This is a test message".to_string()).get();
248        assert_eq!(
249            component.to_ansi(),
250            format!(
251                "{RED}This is a test message{RESET}",
252                RED = Ansi::rgb(0xff0000),
253                RESET = Ansi::RESET
254            )
255        );
256    }
257}