azalea_chat/
text_component.rs

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