azalea_chat/
style.rs

1use std::{collections::HashMap, fmt, sync::LazyLock};
2
3#[cfg(feature = "azalea-buf")]
4use azalea_buf::AzBuf;
5use serde::{Serialize, Serializer, ser::SerializeMap};
6use serde_json::Value;
7#[cfg(feature = "simdnbt")]
8use simdnbt::owned::{NbtCompound, NbtTag};
9
10use crate::{click_event::ClickEvent, hover_event::HoverEvent};
11
12#[derive(Clone, PartialEq, Eq, Debug, Hash)]
13pub struct TextColor {
14    pub value: u32,
15    pub name: Option<String>,
16}
17
18impl Serialize for TextColor {
19    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
20    where
21        S: Serializer,
22    {
23        serializer.serialize_str(&self.serialize())
24    }
25}
26
27#[cfg(feature = "simdnbt")]
28impl simdnbt::ToNbtTag for TextColor {
29    fn to_nbt_tag(self) -> simdnbt::owned::NbtTag {
30        NbtTag::String(self.serialize().into())
31    }
32}
33
34impl TextColor {
35    /// Parse a text component in the same way that Minecraft does.
36    ///
37    /// This supports named colors and hex codes.
38    pub fn parse(value: &str) -> Option<TextColor> {
39        if value.starts_with('#') {
40            let n = value.chars().skip(1).collect::<String>();
41            let n = u32::from_str_radix(&n, 16).ok()?;
42            return Some(TextColor::from_rgb(n));
43        }
44        let color_option = NAMED_COLORS.get(&value.to_ascii_lowercase());
45        if let Some(color) = color_option {
46            return Some(color.clone());
47        }
48        None
49    }
50
51    fn from_rgb(value: u32) -> TextColor {
52        TextColor { value, name: None }
53    }
54}
55
56static LEGACY_FORMAT_TO_COLOR: LazyLock<HashMap<&'static ChatFormatting, TextColor>> =
57    LazyLock::new(|| {
58        let mut legacy_format_to_color = HashMap::new();
59        for formatter in &ChatFormatting::FORMATTERS {
60            if !formatter.is_format() && *formatter != ChatFormatting::Reset {
61                legacy_format_to_color.insert(
62                    formatter,
63                    TextColor {
64                        value: formatter.color().unwrap(),
65                        name: Some(formatter.name().to_string()),
66                    },
67                );
68            }
69        }
70        legacy_format_to_color
71    });
72static NAMED_COLORS: LazyLock<HashMap<String, TextColor>> = LazyLock::new(|| {
73    let mut named_colors = HashMap::new();
74    for color in LEGACY_FORMAT_TO_COLOR.values() {
75        named_colors.insert(color.name.clone().unwrap(), color.clone());
76    }
77    named_colors
78});
79
80pub struct Ansi {}
81impl Ansi {
82    pub const BOLD: &'static str = "\u{1b}[1m";
83    pub const ITALIC: &'static str = "\u{1b}[3m";
84    pub const UNDERLINED: &'static str = "\u{1b}[4m";
85    pub const STRIKETHROUGH: &'static str = "\u{1b}[9m";
86    // "Conceal or hide"
87    pub const OBFUSCATED: &'static str = "\u{1b}[8m";
88    pub const RESET: &'static str = "\u{1b}[m";
89
90    pub fn rgb(value: u32) -> String {
91        format!(
92            "\u{1b}[38;2;{};{};{}m",
93            (value >> 16) & 0xFF,
94            (value >> 8) & 0xFF,
95            value & 0xFF
96        )
97    }
98}
99
100#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
101#[cfg_attr(feature = "azalea-buf", derive(AzBuf))]
102pub enum ChatFormatting {
103    Black,
104    DarkBlue,
105    DarkGreen,
106    DarkAqua,
107    DarkRed,
108    DarkPurple,
109    Gold,
110    Gray,
111    DarkGray,
112    Blue,
113    Green,
114    Aqua,
115    Red,
116    LightPurple,
117    Yellow,
118    White,
119    Obfuscated,
120    Strikethrough,
121    Bold,
122    Underline,
123    Italic,
124    Reset,
125}
126
127impl ChatFormatting {
128    pub const FORMATTERS: [ChatFormatting; 22] = [
129        ChatFormatting::Black,
130        ChatFormatting::DarkBlue,
131        ChatFormatting::DarkGreen,
132        ChatFormatting::DarkAqua,
133        ChatFormatting::DarkRed,
134        ChatFormatting::DarkPurple,
135        ChatFormatting::Gold,
136        ChatFormatting::Gray,
137        ChatFormatting::DarkGray,
138        ChatFormatting::Blue,
139        ChatFormatting::Green,
140        ChatFormatting::Aqua,
141        ChatFormatting::Red,
142        ChatFormatting::LightPurple,
143        ChatFormatting::Yellow,
144        ChatFormatting::White,
145        ChatFormatting::Obfuscated,
146        ChatFormatting::Strikethrough,
147        ChatFormatting::Bold,
148        ChatFormatting::Underline,
149        ChatFormatting::Italic,
150        ChatFormatting::Reset,
151    ];
152
153    pub fn name(&self) -> &'static str {
154        match self {
155            ChatFormatting::Black => "black",
156            ChatFormatting::DarkBlue => "dark_blue",
157            ChatFormatting::DarkGreen => "dark_green",
158            ChatFormatting::DarkAqua => "dark_aqua",
159            ChatFormatting::DarkRed => "dark_red",
160            ChatFormatting::DarkPurple => "dark_purple",
161            ChatFormatting::Gold => "gold",
162            ChatFormatting::Gray => "gray",
163            ChatFormatting::DarkGray => "dark_gray",
164            ChatFormatting::Blue => "blue",
165            ChatFormatting::Green => "green",
166            ChatFormatting::Aqua => "aqua",
167            ChatFormatting::Red => "red",
168            ChatFormatting::LightPurple => "light_purple",
169            ChatFormatting::Yellow => "yellow",
170            ChatFormatting::White => "white",
171            ChatFormatting::Obfuscated => "obfuscated",
172            ChatFormatting::Strikethrough => "strikethrough",
173            ChatFormatting::Bold => "bold",
174            ChatFormatting::Underline => "underline",
175            ChatFormatting::Italic => "italic",
176            ChatFormatting::Reset => "reset",
177        }
178    }
179
180    pub fn code(&self) -> char {
181        match self {
182            ChatFormatting::Black => '0',
183            ChatFormatting::DarkBlue => '1',
184            ChatFormatting::DarkGreen => '2',
185            ChatFormatting::DarkAqua => '3',
186            ChatFormatting::DarkRed => '4',
187            ChatFormatting::DarkPurple => '5',
188            ChatFormatting::Gold => '6',
189            ChatFormatting::Gray => '7',
190            ChatFormatting::DarkGray => '8',
191            ChatFormatting::Blue => '9',
192            ChatFormatting::Green => 'a',
193            ChatFormatting::Aqua => 'b',
194            ChatFormatting::Red => 'c',
195            ChatFormatting::LightPurple => 'd',
196            ChatFormatting::Yellow => 'e',
197            ChatFormatting::White => 'f',
198            ChatFormatting::Obfuscated => 'k',
199            ChatFormatting::Strikethrough => 'm',
200            ChatFormatting::Bold => 'l',
201            ChatFormatting::Underline => 'n',
202            ChatFormatting::Italic => 'o',
203            ChatFormatting::Reset => 'r',
204        }
205    }
206
207    pub fn from_code(code: char) -> Option<ChatFormatting> {
208        match code {
209            '0' => Some(ChatFormatting::Black),
210            '1' => Some(ChatFormatting::DarkBlue),
211            '2' => Some(ChatFormatting::DarkGreen),
212            '3' => Some(ChatFormatting::DarkAqua),
213            '4' => Some(ChatFormatting::DarkRed),
214            '5' => Some(ChatFormatting::DarkPurple),
215            '6' => Some(ChatFormatting::Gold),
216            '7' => Some(ChatFormatting::Gray),
217            '8' => Some(ChatFormatting::DarkGray),
218            '9' => Some(ChatFormatting::Blue),
219            'a' => Some(ChatFormatting::Green),
220            'b' => Some(ChatFormatting::Aqua),
221            'c' => Some(ChatFormatting::Red),
222            'd' => Some(ChatFormatting::LightPurple),
223            'e' => Some(ChatFormatting::Yellow),
224            'f' => Some(ChatFormatting::White),
225            'k' => Some(ChatFormatting::Obfuscated),
226            'm' => Some(ChatFormatting::Strikethrough),
227            'l' => Some(ChatFormatting::Bold),
228            'n' => Some(ChatFormatting::Underline),
229            'o' => Some(ChatFormatting::Italic),
230            'r' => Some(ChatFormatting::Reset),
231            _ => None,
232        }
233    }
234
235    pub fn is_format(&self) -> bool {
236        matches!(
237            self,
238            ChatFormatting::Obfuscated
239                | ChatFormatting::Strikethrough
240                | ChatFormatting::Bold
241                | ChatFormatting::Underline
242                | ChatFormatting::Italic
243                | ChatFormatting::Reset
244        )
245    }
246
247    pub fn color(&self) -> Option<u32> {
248        match self {
249            ChatFormatting::Black => Some(0),
250            ChatFormatting::DarkBlue => Some(170),
251            ChatFormatting::DarkGreen => Some(43520),
252            ChatFormatting::DarkAqua => Some(43690),
253            ChatFormatting::DarkRed => Some(11141120),
254            ChatFormatting::DarkPurple => Some(11141290),
255            ChatFormatting::Gold => Some(16755200),
256            ChatFormatting::Gray => Some(11184810),
257            ChatFormatting::DarkGray => Some(5592405),
258            ChatFormatting::Blue => Some(5592575),
259            ChatFormatting::Green => Some(5635925),
260            ChatFormatting::Aqua => Some(5636095),
261            ChatFormatting::Red => Some(16733525),
262            ChatFormatting::LightPurple => Some(16733695),
263            ChatFormatting::Yellow => Some(16777045),
264            ChatFormatting::White => Some(16777215),
265            _ => None,
266        }
267    }
268}
269
270impl TextColor {
271    fn new(value: u32, name: Option<String>) -> Self {
272        Self { value, name }
273    }
274
275    fn serialize(&self) -> String {
276        if let Some(name) = &self.name {
277            name.clone().to_ascii_lowercase()
278        } else {
279            self.format_value()
280        }
281    }
282
283    pub fn format_value(&self) -> String {
284        format!("#{:06X}", self.value)
285    }
286}
287
288impl fmt::Display for TextColor {
289    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
290        write!(f, "{}", self.serialize())
291    }
292}
293
294// from ChatFormatting to TextColor
295impl TryFrom<ChatFormatting> for TextColor {
296    type Error = String;
297
298    fn try_from(formatter: ChatFormatting) -> Result<Self, Self::Error> {
299        if formatter.is_format() {
300            return Err(format!("{} is not a color", formatter.name()));
301        }
302        let color = formatter.color().unwrap_or(0);
303        Ok(Self::new(color, Some(formatter.name().to_string())))
304    }
305}
306
307macro_rules! define_style_struct {
308    ($($(#[$doc:meta])* $field:ident : $type:ty),* $(,)?) => {
309        #[derive(Clone, Debug, Default, PartialEq, serde::Serialize)]
310        #[non_exhaustive]
311        pub struct Style {
312            $(
313                #[serde(skip_serializing_if = "Option::is_none")]
314                $(#[$doc])*
315                pub $field: Option<$type>,
316            )*
317        }
318
319        impl Style {
320            $(
321                pub fn $field(mut self, value: impl Into<Option<$type>>) -> Self {
322                    self.$field = value.into();
323                    self
324                }
325            )*
326
327            pub fn serialize_map<S>(&self, state: &mut S::SerializeMap) -> Result<(), S::Error>
328            where
329                S: serde::Serializer,
330            {
331                $(
332                    if let Some(value) = &self.$field {
333                        state.serialize_entry(stringify!($field), value)?;
334                    }
335                )*
336                Ok(())
337            }
338
339            /// Apply another style to this one
340            pub fn apply(&mut self, style: &Style) {
341                $(
342                    if let Some(value) = &style.$field {
343                        self.$field = Some(value.clone());
344                    }
345                )*
346            }
347        }
348
349        #[cfg(feature = "simdnbt")]
350        impl simdnbt::Serialize for Style {
351            fn to_compound(self) -> NbtCompound {
352                let mut compound = NbtCompound::new();
353
354                $(
355                    if let Some(value) = self.$field {
356                        compound.insert(stringify!($field), value);
357                    }
358                )*
359
360                compound
361            }
362        }
363    };
364}
365
366define_style_struct! {
367    color: TextColor,
368    shadow_color: u32,
369    bold: bool,
370    italic: bool,
371    underlined: bool,
372    strikethrough: bool,
373    obfuscated: bool,
374    click_event: ClickEvent,
375    hover_event: HoverEvent,
376    insertion: String,
377    /// Represented as a `ResourceLocation`.
378    font: String,
379}
380
381impl Style {
382    pub fn new() -> Self {
383        Self::default()
384    }
385
386    pub fn empty() -> Self {
387        Self::default()
388    }
389
390    pub fn deserialize(json: &Value) -> Style {
391        let Some(j) = json.as_object() else {
392            return Style::default();
393        };
394
395        Style {
396            color: j
397                .get("color")
398                .and_then(|v| v.as_str())
399                .and_then(TextColor::parse),
400            shadow_color: j
401                .get("shadow_color")
402                .and_then(|v| v.as_u64())
403                .map(|v| v as u32),
404            bold: j.get("bold").and_then(|v| v.as_bool()),
405            italic: j.get("italic").and_then(|v| v.as_bool()),
406            underlined: j.get("underlined").and_then(|v| v.as_bool()),
407            strikethrough: j.get("strikethrough").and_then(|v| v.as_bool()),
408            obfuscated: j.get("obfuscated").and_then(|v| v.as_bool()),
409            // TODO: impl deserialize functions for click_event and hover_event
410            click_event: Default::default(),
411            hover_event: Default::default(),
412            insertion: j
413                .get("insertion")
414                .and_then(|v| v.as_str())
415                .map(|s| s.to_string()),
416            font: j
417                .get("font")
418                .and_then(|v| v.as_str())
419                .map(|s| s.to_string()),
420        }
421    }
422
423    /// Check if a style has no attributes set
424    pub fn is_empty(&self) -> bool {
425        self.color.is_none()
426            && self.bold.is_none()
427            && self.italic.is_none()
428            && self.underlined.is_none()
429            && self.strikethrough.is_none()
430            && self.obfuscated.is_none()
431    }
432
433    /// find the necessary ansi code to get from this style to another
434    pub fn compare_ansi(&self, after: &Style) -> String {
435        let should_reset =
436            // if it used to be bold and now it's not, reset
437            (self.bold.unwrap_or_default() && !after.bold.unwrap_or_default()) ||
438            // if it used to be italic and now it's not, reset
439            (self.italic.unwrap_or_default() && !after.italic.unwrap_or_default()) ||
440            // if it used to be underlined and now it's not, reset
441            (self.underlined.unwrap_or_default() && !after.underlined.unwrap_or_default()) ||
442            // if it used to be strikethrough and now it's not, reset
443            (self.strikethrough.unwrap_or_default() && !after.strikethrough.unwrap_or_default()) ||
444            // if it used to be obfuscated and now it's not, reset
445            (self.obfuscated.unwrap_or_default() && !after.obfuscated.unwrap_or_default());
446
447        let mut ansi_codes = String::new();
448
449        let empty_style = Style::empty();
450
451        let before = if should_reset {
452            ansi_codes.push_str(Ansi::RESET);
453            &empty_style
454        } else {
455            self
456        };
457
458        // if bold used to be false/default and now it's true, set bold
459        if !before.bold.unwrap_or_default() && after.bold.unwrap_or_default() {
460            ansi_codes.push_str(Ansi::BOLD);
461        }
462        // if italic used to be false/default and now it's true, set italic
463        if !before.italic.unwrap_or_default() && after.italic.unwrap_or_default() {
464            ansi_codes.push_str(Ansi::ITALIC);
465        }
466        // if underlined used to be false/default and now it's true, set underlined
467        if !before.underlined.unwrap_or_default() && after.underlined.unwrap_or_default() {
468            ansi_codes.push_str(Ansi::UNDERLINED);
469        }
470        // if strikethrough used to be false/default and now it's true, set
471        // strikethrough
472        if !before.strikethrough.unwrap_or_default() && after.strikethrough.unwrap_or_default() {
473            ansi_codes.push_str(Ansi::STRIKETHROUGH);
474        }
475        // if obfuscated used to be false/default and now it's true, set obfuscated
476        if !before.obfuscated.unwrap_or_default() && after.obfuscated.unwrap_or_default() {
477            ansi_codes.push_str(Ansi::OBFUSCATED);
478        }
479
480        // if the new color is different and not none, set color
481        let color_changed = {
482            if before.color.is_none() && after.color.is_some() {
483                true
484            } else if before.color.is_some() && after.color.is_some() {
485                before.color.clone().unwrap().value != after.color.as_ref().unwrap().value
486            } else {
487                false
488            }
489        };
490
491        if color_changed {
492            let after_color = after.color.as_ref().unwrap();
493            ansi_codes.push_str(&Ansi::rgb(after_color.value));
494        }
495
496        ansi_codes
497    }
498
499    /// Returns a new style that is a merge of self and other.
500    /// For any field that `other` does not specify (is None), self’s value is
501    /// used.
502    pub fn merged_with(&self, other: &Style) -> Style {
503        Style {
504            color: other.color.clone().or(self.color.clone()),
505            shadow_color: other.shadow_color.or(self.shadow_color),
506            bold: other.bold.or(self.bold),
507            italic: other.italic.or(self.italic),
508            underlined: other.underlined.or(self.underlined),
509            strikethrough: other.strikethrough.or(self.strikethrough),
510            obfuscated: other.obfuscated.or(self.obfuscated),
511            click_event: other.click_event.clone().or(self.click_event.clone()),
512            hover_event: other.hover_event.clone().or(self.hover_event.clone()),
513            insertion: other.insertion.clone().or(self.insertion.clone()),
514            font: other.font.clone().or(self.font.clone()),
515        }
516    }
517
518    /// Apply a ChatFormatting to this style
519    pub fn apply_formatting(&mut self, formatting: &ChatFormatting) {
520        match *formatting {
521            ChatFormatting::Bold => self.bold = Some(true),
522            ChatFormatting::Italic => self.italic = Some(true),
523            ChatFormatting::Underline => self.underlined = Some(true),
524            ChatFormatting::Strikethrough => self.strikethrough = Some(true),
525            ChatFormatting::Obfuscated => self.obfuscated = Some(true),
526            ChatFormatting::Reset => {
527                self.color = None;
528                self.bold = None;
529                self.italic = None;
530                self.underlined = None;
531                self.strikethrough = None;
532                self.obfuscated = None;
533            }
534            formatter => {
535                // if it's a color, set it
536                if let Some(color) = formatter.color() {
537                    self.color = Some(TextColor::from_rgb(color));
538                }
539            }
540        }
541    }
542
543    pub fn get_html_style(&self) -> String {
544        let mut style = String::new();
545        if let Some(color) = &self.color {
546            style.push_str(&format!("color:{};", color.format_value()));
547        }
548        if let Some(bold) = self.bold {
549            style.push_str(&format!(
550                "font-weight:{};",
551                if bold { "bold" } else { "normal" }
552            ));
553        }
554        if let Some(italic) = self.italic {
555            style.push_str(&format!(
556                "font-style:{};",
557                if italic { "italic" } else { "normal" }
558            ));
559        }
560        if let Some(underlined) = self.underlined {
561            style.push_str(&format!(
562                "text-decoration:{};",
563                if underlined { "underline" } else { "none" }
564            ));
565        }
566        if let Some(strikethrough) = self.strikethrough {
567            style.push_str(&format!(
568                "text-decoration:{};",
569                if strikethrough {
570                    "line-through"
571                } else {
572                    "none"
573                }
574            ));
575        }
576        if let Some(obfuscated) = self.obfuscated
577            && obfuscated
578        {
579            style.push_str("filter:blur(2px);");
580        }
581
582        style
583    }
584}
585
586#[cfg(feature = "simdnbt")]
587impl simdnbt::Deserialize for Style {
588    fn from_compound(
589        compound: simdnbt::borrow::NbtCompound,
590    ) -> Result<Self, simdnbt::DeserializeError> {
591        let bold = compound.byte("bold").map(|v| v != 0);
592        let italic = compound.byte("italic").map(|v| v != 0);
593        let underlined = compound.byte("underlined").map(|v| v != 0);
594        let strikethrough = compound.byte("strikethrough").map(|v| v != 0);
595        let obfuscated = compound.byte("obfuscated").map(|v| v != 0);
596        let color: Option<TextColor> = compound
597            .string("color")
598            .and_then(|v| TextColor::parse(&v.to_str()));
599        Ok(Style {
600            color,
601            bold,
602            italic,
603            underlined,
604            strikethrough,
605            obfuscated,
606            ..Style::default()
607        })
608    }
609}
610
611#[cfg(test)]
612mod tests {
613    use super::*;
614
615    #[test]
616    fn text_color_named_colors() {
617        assert_eq!(TextColor::parse("red").unwrap().value, 16733525);
618    }
619    #[test]
620    fn text_color_hex_colors() {
621        assert_eq!(TextColor::parse("#a1b2c3").unwrap().value, 10597059);
622    }
623
624    #[test]
625    fn ansi_difference_should_reset() {
626        let style_a = Style {
627            bold: Some(true),
628            italic: Some(true),
629            ..Style::default()
630        };
631        let style_b = Style {
632            bold: Some(false),
633            italic: Some(true),
634            ..Style::default()
635        };
636        let ansi_difference = style_a.compare_ansi(&style_b);
637        assert_eq!(
638            ansi_difference,
639            format!(
640                "{reset}{italic}",
641                reset = Ansi::RESET,
642                italic = Ansi::ITALIC
643            )
644        )
645    }
646    #[test]
647    fn ansi_difference_shouldnt_reset() {
648        let style_a = Style {
649            bold: Some(true),
650            ..Style::default()
651        };
652        let style_b = Style {
653            bold: Some(true),
654            italic: Some(true),
655            ..Style::default()
656        };
657        let ansi_difference = style_a.compare_ansi(&style_b);
658        assert_eq!(ansi_difference, Ansi::ITALIC)
659    }
660
661    #[test]
662    fn test_from_code() {
663        assert_eq!(
664            ChatFormatting::from_code('a').unwrap(),
665            ChatFormatting::Green
666        );
667    }
668
669    #[test]
670    fn test_apply_formatting() {
671        let mut style = Style::default();
672        style.apply_formatting(&ChatFormatting::Bold);
673        style.apply_formatting(&ChatFormatting::Red);
674        assert_eq!(style.color, Some(TextColor::from_rgb(16733525)));
675    }
676}