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