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