azalea_chat/
component.rs

1#[cfg(all(feature = "azalea-buf", feature = "simdnbt"))]
2use std::io::{self, Cursor, Write};
3use std::{
4    fmt::{self, Display},
5    sync::LazyLock,
6};
7
8#[cfg(feature = "azalea-buf")]
9use azalea_buf::{AzaleaRead, AzaleaWrite, BufReadError};
10use serde::{Deserialize, Deserializer, Serialize, de};
11#[cfg(feature = "simdnbt")]
12use simdnbt::{Deserialize as _, FromNbtTag as _, Serialize as _};
13#[cfg(all(feature = "azalea-buf", feature = "simdnbt"))]
14use tracing::{debug, trace, warn};
15
16use crate::{
17    base_component::BaseComponent,
18    style::{ChatFormatting, Style},
19    text_component::TextComponent,
20    translatable_component::{StringOrComponent, TranslatableComponent},
21};
22
23/// A chat component, basically anything you can see in chat.
24#[derive(Clone, Debug, PartialEq, Eq, Serialize, Hash)]
25#[serde(untagged)]
26pub enum FormattedText {
27    Text(TextComponent),
28    Translatable(TranslatableComponent),
29}
30
31pub static DEFAULT_STYLE: LazyLock<Style> = LazyLock::new(|| Style {
32    color: Some(ChatFormatting::White.try_into().unwrap()),
33    ..Style::default()
34});
35
36/// A chat component
37impl FormattedText {
38    pub fn get_base_mut(&mut self) -> &mut BaseComponent {
39        match self {
40            Self::Text(c) => &mut c.base,
41            Self::Translatable(c) => &mut c.base,
42        }
43    }
44
45    pub fn get_base(&self) -> &BaseComponent {
46        match self {
47            Self::Text(c) => &c.base,
48            Self::Translatable(c) => &c.base,
49        }
50    }
51
52    /// Add a component as a sibling of this one
53    fn append(&mut self, sibling: FormattedText) {
54        self.get_base_mut().siblings.push(sibling);
55    }
56
57    /// Get the "separator" component from the json
58    fn parse_separator(
59        json: &serde_json::Value,
60    ) -> Result<Option<FormattedText>, serde_json::Error> {
61        if let Some(separator) = json.get("separator") {
62            return Ok(Some(FormattedText::deserialize(separator)?));
63        }
64        Ok(None)
65    }
66
67    #[cfg(feature = "simdnbt")]
68    fn parse_separator_nbt(nbt: &simdnbt::borrow::NbtCompound) -> Option<FormattedText> {
69        if let Some(separator) = nbt.get("separator") {
70            FormattedText::from_nbt_tag(separator)
71        } else {
72            None
73        }
74    }
75
76    /// Render all components into a single `String`, using your custom
77    /// closures to drive styling, text transformation, and final cleanup.
78    ///
79    /// # Type params
80    /// - `F`: `(running, component, default) -> (prefix, suffix)` for
81    ///   per-component styling
82    /// - `S`: `&str -> String` for text tweaks (escaping, mapping, etc.)
83    /// - `C`: `&final_running_style -> String` for any trailing cleanup
84    ///
85    /// # Args
86    /// - `style_formatter`: how to open/close each component’s style
87    /// - `text_formatter`:  how to turn raw text into output text
88    /// - `cleanup_formatter`: emit after all components (e.g. reset codes)
89    /// - `default_style`:    where to reset when a component’s `reset` is true
90    ///
91    /// # Example
92    /// ```rust
93    /// use azalea_chat::{FormattedText, DEFAULT_STYLE};
94    /// use serde::de::Deserialize;
95    ///
96    /// let component = FormattedText::deserialize(&serde_json::json!({
97    ///    "text": "Hello, world!",
98    ///    "color": "red",
99    /// })).unwrap();
100    ///
101    /// let ansi = component.to_custom_format(
102    ///     |running, new, default| (running.compare_ansi(new, default), String::new()),
103    ///     |text| text.to_string(),
104    ///     |style| {
105    ///         if !style.is_empty() {
106    ///             "\u{1b}[m".to_string()
107    ///         } else {
108    ///             String::new()
109    ///         }
110    ///     },
111    ///     &DEFAULT_STYLE,
112    /// );
113    /// println!("{}", ansi);
114    /// ```
115    pub fn to_custom_format<F, S, C>(
116        &self,
117        mut style_formatter: F,
118        mut text_formatter: S,
119        mut cleanup_formatter: C,
120        default_style: &Style,
121    ) -> String
122    where
123        F: FnMut(&Style, &Style, &Style) -> (String, String),
124        S: FnMut(&str) -> String,
125        C: FnMut(&Style) -> String,
126    {
127        let mut output = String::new();
128        let mut running_style = Style::default();
129
130        for component in self.clone().into_iter() {
131            let component_text = match &component {
132                Self::Text(c) => c.text.to_string(),
133                Self::Translatable(c) => match c.read() {
134                    Ok(c) => c.to_string(),
135                    Err(_) => c.key.to_string(),
136                },
137            };
138
139            let component_style = &component.get_base().style;
140
141            let formatted_style = style_formatter(&running_style, component_style, default_style);
142            let formatted_text = text_formatter(&component_text);
143
144            output.push_str(&formatted_style.0);
145            output.push_str(&formatted_text);
146            output.push_str(&formatted_style.1);
147
148            // Reset running style if required
149            if component_style.reset {
150                running_style = default_style.clone();
151            } else {
152                running_style.apply(component_style);
153            }
154        }
155
156        output.push_str(&cleanup_formatter(&running_style));
157
158        output
159    }
160
161    /// Convert this component into an
162    /// [ANSI string](https://en.wikipedia.org/wiki/ANSI_escape_code).
163    ///
164    /// This is the same as [`FormattedText::to_ansi`], but you can specify a
165    /// default [`Style`] to use.
166    pub fn to_ansi_with_custom_style(&self, default_style: &Style) -> String {
167        self.to_custom_format(
168            |running, new, default| (running.compare_ansi(new, default), "".to_owned()),
169            |text| text.to_string(),
170            |style| if !style.is_empty() { "\u{1b}[m" } else { "" }.to_string(),
171            default_style,
172        )
173    }
174
175    /// Convert this component into an
176    /// [ANSI string](https://en.wikipedia.org/wiki/ANSI_escape_code), so you
177    /// can print it to your terminal and get styling.
178    ///
179    /// This is technically a shortcut for
180    /// [`FormattedText::to_ansi_with_custom_style`] with a default [`Style`]
181    /// colored white.
182    ///
183    /// # Examples
184    ///
185    /// ```rust
186    /// use azalea_chat::FormattedText;
187    /// use serde::de::Deserialize;
188    ///
189    /// let component = FormattedText::deserialize(&serde_json::json!({
190    ///    "text": "Hello, world!",
191    ///    "color": "red",
192    /// })).unwrap();
193    ///
194    /// println!("{}", component.to_ansi());
195    /// ```
196    pub fn to_ansi(&self) -> String {
197        self.to_ansi_with_custom_style(&DEFAULT_STYLE)
198    }
199
200    pub fn to_html(&self) -> String {
201        self.to_custom_format(
202            |running, new, _| {
203                (
204                    format!(
205                        "<span style=\"{}\">",
206                        running.merged_with(new).get_html_style()
207                    ),
208                    "</span>".to_owned(),
209                )
210            },
211            |text| {
212                text.replace("&", "&amp;")
213                    .replace("<", "&lt;")
214                    // usually unnecessary but good for compatibility
215                    .replace(">", "&gt;")
216                    .replace("\n", "<br>")
217            },
218            |_| "".to_string(),
219            &DEFAULT_STYLE,
220        )
221    }
222}
223
224impl IntoIterator for FormattedText {
225    /// Recursively call the function for every component in this component
226    fn into_iter(self) -> Self::IntoIter {
227        let base = self.get_base();
228        let siblings = base.siblings.clone();
229        let mut v: Vec<FormattedText> = Vec::with_capacity(siblings.len() + 1);
230        v.push(self);
231        for sibling in siblings {
232            v.extend(sibling);
233        }
234
235        v.into_iter()
236    }
237
238    type Item = FormattedText;
239    type IntoIter = std::vec::IntoIter<Self::Item>;
240}
241
242impl<'de> Deserialize<'de> for FormattedText {
243    fn deserialize<D>(de: D) -> Result<Self, D::Error>
244    where
245        D: Deserializer<'de>,
246    {
247        let json: serde_json::Value = serde::Deserialize::deserialize(de)?;
248
249        // we create a component that we might add siblings to
250        let mut component: FormattedText;
251
252        // if it's primitive, make it a text component
253        if !json.is_array() && !json.is_object() {
254            return Ok(FormattedText::Text(TextComponent::new(
255                json.as_str().unwrap_or("").to_string(),
256            )));
257        }
258        // if it's an object, do things with { text } and stuff
259        else if json.is_object() {
260            if let Some(text) = json.get("text") {
261                let text = text.as_str().unwrap_or("").to_string();
262                component = FormattedText::Text(TextComponent::new(text));
263            } else if let Some(translate) = json.get("translate") {
264                let translate = translate
265                    .as_str()
266                    .ok_or_else(|| de::Error::custom("\"translate\" must be a string"))?
267                    .into();
268                if let Some(with) = json.get("with") {
269                    let with = with
270                        .as_array()
271                        .ok_or_else(|| de::Error::custom("\"with\" must be an array"))?;
272                    let mut with_array = Vec::with_capacity(with.len());
273                    for item in with {
274                        // if it's a string component with no styling and no siblings, just add a
275                        // string to with_array otherwise add the component
276                        // to the array
277                        let c = FormattedText::deserialize(item).map_err(de::Error::custom)?;
278                        if let FormattedText::Text(text_component) = c
279                            && text_component.base.siblings.is_empty()
280                            && text_component.base.style.is_empty()
281                        {
282                            with_array.push(StringOrComponent::String(text_component.text));
283                            continue;
284                        }
285                        with_array.push(StringOrComponent::FormattedText(
286                            FormattedText::deserialize(item).map_err(de::Error::custom)?,
287                        ));
288                    }
289                    component = FormattedText::Translatable(TranslatableComponent::new(
290                        translate, with_array,
291                    ));
292                } else {
293                    // if it doesn't have a "with", just have the with_array be empty
294                    component = FormattedText::Translatable(TranslatableComponent::new(
295                        translate,
296                        Vec::new(),
297                    ));
298                }
299            } else if let Some(score) = json.get("score") {
300                // object = GsonHelper.getAsJsonObject(jsonObject, "score");
301                if score.get("name").is_none() || score.get("objective").is_none() {
302                    return Err(de::Error::missing_field(
303                        "A score component needs at least a name and an objective",
304                    ));
305                }
306                // TODO
307                return Err(de::Error::custom(
308                    "score text components aren't yet supported",
309                ));
310            } else if json.get("selector").is_some() {
311                return Err(de::Error::custom(
312                    "selector text components aren't yet supported",
313                ));
314            } else if json.get("keybind").is_some() {
315                return Err(de::Error::custom(
316                    "keybind text components aren't yet supported",
317                ));
318            } else {
319                let Some(_nbt) = json.get("nbt") else {
320                    return Err(de::Error::custom(
321                        format!("Don't know how to turn {json} into a FormattedText").as_str(),
322                    ));
323                };
324                let _separator =
325                    FormattedText::parse_separator(&json).map_err(de::Error::custom)?;
326
327                let _interpret = match json.get("interpret") {
328                    Some(v) => v.as_bool().ok_or(Some(false)).unwrap(),
329                    None => false,
330                };
331                if let Some(_block) = json.get("block") {}
332                return Err(de::Error::custom(
333                    "nbt text components aren't yet supported",
334                ));
335            }
336            if let Some(extra) = json.get("extra") {
337                let Some(extra) = extra.as_array() else {
338                    return Err(de::Error::custom("Extra isn't an array"));
339                };
340                if extra.is_empty() {
341                    return Err(de::Error::custom("Unexpected empty array of components"));
342                }
343                for extra_component in extra {
344                    let sibling =
345                        FormattedText::deserialize(extra_component).map_err(de::Error::custom)?;
346                    component.append(sibling);
347                }
348            }
349
350            let style = Style::deserialize(&json);
351            component.get_base_mut().style = style;
352
353            return Ok(component);
354        }
355        // ok so it's not an object, if it's an array deserialize every item
356        else if !json.is_array() {
357            return Err(de::Error::custom(
358                format!("Don't know how to turn {json} into a FormattedText").as_str(),
359            ));
360        }
361        let json_array = json.as_array().unwrap();
362        // the first item in the array is the one that we're gonna return, the others
363        // are siblings
364        let mut component =
365            FormattedText::deserialize(&json_array[0]).map_err(de::Error::custom)?;
366        for i in 1..json_array.len() {
367            component.append(
368                FormattedText::deserialize(json_array.get(i).unwrap())
369                    .map_err(de::Error::custom)?,
370            );
371        }
372        Ok(component)
373    }
374}
375
376#[cfg(feature = "simdnbt")]
377impl simdnbt::Serialize for FormattedText {
378    fn to_compound(self) -> simdnbt::owned::NbtCompound {
379        match self {
380            FormattedText::Text(c) => c.to_compound(),
381            FormattedText::Translatable(c) => c.to_compound(),
382        }
383    }
384}
385
386#[cfg(feature = "simdnbt")]
387impl simdnbt::FromNbtTag for FormattedText {
388    fn from_nbt_tag(tag: simdnbt::borrow::NbtTag) -> Option<Self> {
389        // if it's a string, return a text component with that string
390        if let Some(string) = tag.string() {
391            Some(FormattedText::from(string))
392        }
393        // if it's a compound, make it do things with { text } and stuff
394        // simdnbt::borrow::NbtTag::Compound(compound) => {
395        else if let Some(compound) = tag.compound() {
396            FormattedText::from_nbt_compound(compound)
397        }
398        // ok so it's not a compound, if it's a list deserialize every item
399        else if let Some(list) = tag.list() {
400            let mut component;
401            if let Some(compounds) = list.compounds() {
402                component = FormattedText::from_nbt_compound(compounds.first()?)?;
403                for compound in compounds.into_iter().skip(1) {
404                    component.append(FormattedText::from_nbt_compound(compound)?);
405                }
406            } else if let Some(strings) = list.strings() {
407                component = FormattedText::from(*(strings.first()?));
408                for &string in strings.iter().skip(1) {
409                    component.append(FormattedText::from(string));
410                }
411            } else {
412                debug!("couldn't parse {list:?} as FormattedText");
413                return None;
414            }
415            Some(component)
416        } else {
417            Some(FormattedText::Text(TextComponent::new("".to_owned())))
418        }
419    }
420}
421
422#[cfg(feature = "simdnbt")]
423impl FormattedText {
424    pub fn from_nbt_compound(compound: simdnbt::borrow::NbtCompound) -> Option<Self> {
425        let mut component: FormattedText;
426
427        if let Some(text) = compound.get("text") {
428            let text = text.string().unwrap_or_default().to_string();
429            component = FormattedText::Text(TextComponent::new(text));
430        } else if let Some(translate) = compound.get("translate") {
431            let translate = translate.string()?.into();
432            if let Some(with) = compound.get("with") {
433                let mut with_array = Vec::new();
434                let with_list = with.list()?;
435                if with_list.empty() {
436                } else if let Some(with) = with_list.strings() {
437                    for item in with {
438                        with_array.push(StringOrComponent::String(item.to_string()));
439                    }
440                } else if let Some(with) = with_list.ints() {
441                    for item in with {
442                        with_array.push(StringOrComponent::String(item.to_string()));
443                    }
444                } else if let Some(with) = with_list.compounds() {
445                    for item in with {
446                        // if it's a string component with no styling and no siblings,
447                        // just add a string to
448                        // with_array otherwise add the
449                        // component to the array
450                        if let Some(primitive) = item.get("") {
451                            // minecraft does this sometimes, for example
452                            // for the /give system messages
453                            if let Some(b) = primitive.byte() {
454                                // interpreted as boolean
455                                with_array.push(StringOrComponent::String(
456                                    if b != 0 { "true" } else { "false" }.to_string(),
457                                ));
458                            } else if let Some(s) = primitive.short() {
459                                with_array.push(StringOrComponent::String(s.to_string()));
460                            } else if let Some(i) = primitive.int() {
461                                with_array.push(StringOrComponent::String(i.to_string()));
462                            } else if let Some(l) = primitive.long() {
463                                with_array.push(StringOrComponent::String(l.to_string()));
464                            } else if let Some(f) = primitive.float() {
465                                with_array.push(StringOrComponent::String(f.to_string()));
466                            } else if let Some(d) = primitive.double() {
467                                with_array.push(StringOrComponent::String(d.to_string()));
468                            } else if let Some(s) = primitive.string() {
469                                with_array.push(StringOrComponent::String(s.to_string()));
470                            } else {
471                                warn!(
472                                    "couldn't parse {item:?} as FormattedText because it has a disallowed primitive"
473                                );
474                                with_array.push(StringOrComponent::String("?".to_string()));
475                            }
476                        } else if let Some(c) = FormattedText::from_nbt_compound(item) {
477                            if let FormattedText::Text(text_component) = c
478                                && text_component.base.siblings.is_empty()
479                                && text_component.base.style.is_empty()
480                            {
481                                with_array.push(StringOrComponent::String(text_component.text));
482                                continue;
483                            }
484                            with_array.push(StringOrComponent::FormattedText(
485                                FormattedText::from_nbt_compound(item)?,
486                            ));
487                        } else {
488                            warn!("couldn't parse {item:?} as FormattedText");
489                            with_array.push(StringOrComponent::String("?".to_string()));
490                        }
491                    }
492                } else {
493                    warn!(
494                        "couldn't parse {with:?} as FormattedText because it's not a list of compounds"
495                    );
496                    return None;
497                }
498                component =
499                    FormattedText::Translatable(TranslatableComponent::new(translate, with_array));
500            } else {
501                // if it doesn't have a "with", just have the with_array be empty
502                component =
503                    FormattedText::Translatable(TranslatableComponent::new(translate, Vec::new()));
504            }
505        } else if let Some(score) = compound.compound("score") {
506            // object = GsonHelper.getAsJsonObject(jsonObject, "score");
507            if score.get("name").is_none() || score.get("objective").is_none() {
508                // A score component needs at least a name and an objective
509                trace!("A score component needs at least a name and an objective");
510                return None;
511            }
512            // TODO, score text components aren't yet supported
513            return None;
514        } else if compound.get("selector").is_some() {
515            // selector text components aren't yet supported
516            trace!("selector text components aren't yet supported");
517            return None;
518        } else if compound.get("keybind").is_some() {
519            // keybind text components aren't yet supported
520            trace!("keybind text components aren't yet supported");
521            return None;
522        } else if let Some(tag) = compound.get("") {
523            return FormattedText::from_nbt_tag(tag);
524        } else {
525            let _nbt = compound.get("nbt")?;
526            let _separator = FormattedText::parse_separator_nbt(&compound)?;
527
528            let _interpret = match compound.get("interpret") {
529                Some(v) => v.byte().unwrap_or_default() != 0,
530                None => false,
531            };
532            if let Some(_block) = compound.get("block") {}
533            trace!("nbt text components aren't yet supported");
534            return None;
535        }
536        if let Some(extra) = compound.get("extra") {
537            component.append(FormattedText::from_nbt_tag(extra)?);
538        }
539
540        let base_style = Style::from_compound(compound).ok()?;
541        let new_style = &mut component.get_base_mut().style;
542        *new_style = new_style.merged_with(&base_style);
543
544        Some(component)
545    }
546}
547
548#[cfg(feature = "simdnbt")]
549impl From<&simdnbt::Mutf8Str> for FormattedText {
550    fn from(s: &simdnbt::Mutf8Str) -> Self {
551        FormattedText::Text(TextComponent::new(s.to_string()))
552    }
553}
554
555#[cfg(all(feature = "azalea-buf", feature = "simdnbt"))]
556impl AzaleaRead for FormattedText {
557    fn azalea_read(buf: &mut Cursor<&[u8]>) -> Result<Self, BufReadError> {
558        let nbt = simdnbt::borrow::read_optional_tag(buf)?;
559        match nbt {
560            Some(nbt) => FormattedText::from_nbt_tag(nbt.as_tag()).ok_or(BufReadError::Custom(
561                "couldn't convert nbt to chat message".to_owned(),
562            )),
563            _ => Ok(FormattedText::default()),
564        }
565    }
566}
567
568#[cfg(all(feature = "azalea-buf", feature = "simdnbt"))]
569impl AzaleaWrite for FormattedText {
570    fn azalea_write(&self, buf: &mut impl Write) -> io::Result<()> {
571        let mut out = Vec::new();
572        simdnbt::owned::BaseNbt::write_unnamed(&(self.clone().to_compound().into()), &mut out);
573        buf.write_all(&out)
574    }
575}
576
577impl From<String> for FormattedText {
578    fn from(s: String) -> Self {
579        FormattedText::Text(TextComponent {
580            text: s,
581            base: BaseComponent::default(),
582        })
583    }
584}
585impl From<&str> for FormattedText {
586    fn from(s: &str) -> Self {
587        Self::from(s.to_string())
588    }
589}
590
591impl Display for FormattedText {
592    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
593        match self {
594            FormattedText::Text(c) => c.fmt(f),
595            FormattedText::Translatable(c) => c.fmt(f),
596        }
597    }
598}
599
600impl Default for FormattedText {
601    fn default() -> Self {
602        FormattedText::Text(TextComponent::default())
603    }
604}