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, Debug, Eq, Hash, PartialEq)]
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_owned()),
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, Debug, Eq, Hash, PartialEq)]
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_owned())))
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 an `Identifier`.
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_owned()),
416            font: j.get("font").and_then(|v| v.as_str()).map(|s| s.to_owned()),
417        }
418    }
419
420    /// Check if a style has no attributes set
421    pub fn is_empty(&self) -> bool {
422        self.color.is_none()
423            && self.bold.is_none()
424            && self.italic.is_none()
425            && self.underlined.is_none()
426            && self.strikethrough.is_none()
427            && self.obfuscated.is_none()
428    }
429
430    /// find the necessary ansi code to get from this style to another
431    pub fn compare_ansi(&self, after: &Style) -> String {
432        let should_reset =
433            // if it used to be bold and now it's not, reset
434            (self.bold.unwrap_or_default() && !after.bold.unwrap_or_default()) ||
435            // if it used to be italic and now it's not, reset
436            (self.italic.unwrap_or_default() && !after.italic.unwrap_or_default()) ||
437            // if it used to be underlined and now it's not, reset
438            (self.underlined.unwrap_or_default() && !after.underlined.unwrap_or_default()) ||
439            // if it used to be strikethrough and now it's not, reset
440            (self.strikethrough.unwrap_or_default() && !after.strikethrough.unwrap_or_default()) ||
441            // if it used to be obfuscated and now it's not, reset
442            (self.obfuscated.unwrap_or_default() && !after.obfuscated.unwrap_or_default());
443
444        let mut ansi_codes = String::new();
445
446        let empty_style = Style::empty();
447
448        let before = if should_reset {
449            ansi_codes.push_str(Ansi::RESET);
450            &empty_style
451        } else {
452            self
453        };
454
455        // if bold used to be false/default and now it's true, set bold
456        if !before.bold.unwrap_or_default() && after.bold.unwrap_or_default() {
457            ansi_codes.push_str(Ansi::BOLD);
458        }
459        // if italic used to be false/default and now it's true, set italic
460        if !before.italic.unwrap_or_default() && after.italic.unwrap_or_default() {
461            ansi_codes.push_str(Ansi::ITALIC);
462        }
463        // if underlined used to be false/default and now it's true, set underlined
464        if !before.underlined.unwrap_or_default() && after.underlined.unwrap_or_default() {
465            ansi_codes.push_str(Ansi::UNDERLINED);
466        }
467        // if strikethrough used to be false/default and now it's true, set
468        // strikethrough
469        if !before.strikethrough.unwrap_or_default() && after.strikethrough.unwrap_or_default() {
470            ansi_codes.push_str(Ansi::STRIKETHROUGH);
471        }
472        // if obfuscated used to be false/default and now it's true, set obfuscated
473        if !before.obfuscated.unwrap_or_default() && after.obfuscated.unwrap_or_default() {
474            ansi_codes.push_str(Ansi::OBFUSCATED);
475        }
476
477        // if the new color is different and not none, set color
478        let color_changed = {
479            if before.color.is_none() && after.color.is_some() {
480                true
481            } else if before.color.is_some() && after.color.is_some() {
482                before.color.clone().unwrap().value != after.color.as_ref().unwrap().value
483            } else {
484                false
485            }
486        };
487
488        if color_changed {
489            let after_color = after.color.as_ref().unwrap();
490            ansi_codes.push_str(&Ansi::rgb(after_color.value));
491        }
492
493        ansi_codes
494    }
495
496    /// Returns a new style that is a merge of self and other.
497    /// For any field that `other` does not specify (is None), self's value is
498    /// used.
499    pub fn merged_with(&self, other: &Style) -> Style {
500        Style {
501            color: other.color.clone().or(self.color.clone()),
502            shadow_color: other.shadow_color.or(self.shadow_color),
503            bold: other.bold.or(self.bold),
504            italic: other.italic.or(self.italic),
505            underlined: other.underlined.or(self.underlined),
506            strikethrough: other.strikethrough.or(self.strikethrough),
507            obfuscated: other.obfuscated.or(self.obfuscated),
508            click_event: other.click_event.clone().or(self.click_event.clone()),
509            hover_event: other.hover_event.clone().or(self.hover_event.clone()),
510            insertion: other.insertion.clone().or(self.insertion.clone()),
511            font: other.font.clone().or(self.font.clone()),
512        }
513    }
514
515    /// Apply a ChatFormatting to this style
516    pub fn apply_formatting(&mut self, formatting: &ChatFormatting) {
517        match *formatting {
518            ChatFormatting::Bold => self.bold = Some(true),
519            ChatFormatting::Italic => self.italic = Some(true),
520            ChatFormatting::Underline => self.underlined = Some(true),
521            ChatFormatting::Strikethrough => self.strikethrough = Some(true),
522            ChatFormatting::Obfuscated => self.obfuscated = Some(true),
523            ChatFormatting::Reset => {
524                self.color = None;
525                self.bold = None;
526                self.italic = None;
527                self.underlined = None;
528                self.strikethrough = None;
529                self.obfuscated = None;
530            }
531            formatter => {
532                // if it's a color, set it
533                if let Some(color) = formatter.color() {
534                    self.color = Some(TextColor::from_rgb(color));
535                }
536            }
537        }
538    }
539
540    pub fn get_html_style(&self) -> String {
541        let mut style = String::new();
542        if let Some(color) = &self.color {
543            style.push_str(&format!("color:{};", color.format_value()));
544        }
545        if let Some(bold) = self.bold {
546            style.push_str(&format!(
547                "font-weight:{};",
548                if bold { "bold" } else { "normal" }
549            ));
550        }
551        if let Some(italic) = self.italic {
552            style.push_str(&format!(
553                "font-style:{};",
554                if italic { "italic" } else { "normal" }
555            ));
556        }
557        if let Some(underlined) = self.underlined {
558            style.push_str(&format!(
559                "text-decoration:{};",
560                if underlined { "underline" } else { "none" }
561            ));
562        }
563        if let Some(strikethrough) = self.strikethrough {
564            style.push_str(&format!(
565                "text-decoration:{};",
566                if strikethrough {
567                    "line-through"
568                } else {
569                    "none"
570                }
571            ));
572        }
573        if let Some(obfuscated) = self.obfuscated
574            && obfuscated
575        {
576            style.push_str("filter:blur(2px);");
577        }
578
579        style
580    }
581}
582
583#[cfg(feature = "simdnbt")]
584impl simdnbt::Deserialize for Style {
585    fn from_compound(
586        compound: simdnbt::borrow::NbtCompound,
587    ) -> Result<Self, simdnbt::DeserializeError> {
588        let bold = compound.byte("bold").map(|v| v != 0);
589        let italic = compound.byte("italic").map(|v| v != 0);
590        let underlined = compound.byte("underlined").map(|v| v != 0);
591        let strikethrough = compound.byte("strikethrough").map(|v| v != 0);
592        let obfuscated = compound.byte("obfuscated").map(|v| v != 0);
593        let color: Option<TextColor> = compound
594            .string("color")
595            .and_then(|v| TextColor::parse(&v.to_str()));
596        Ok(Style {
597            color,
598            bold,
599            italic,
600            underlined,
601            strikethrough,
602            obfuscated,
603            ..Style::default()
604        })
605    }
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611
612    #[test]
613    fn text_color_named_colors() {
614        assert_eq!(TextColor::parse("red").unwrap().value, 16733525);
615    }
616    #[test]
617    fn text_color_hex_colors() {
618        assert_eq!(TextColor::parse("#a1b2c3").unwrap().value, 10597059);
619    }
620
621    #[test]
622    fn ansi_difference_should_reset() {
623        let style_a = Style {
624            bold: Some(true),
625            italic: Some(true),
626            ..Style::default()
627        };
628        let style_b = Style {
629            bold: Some(false),
630            italic: Some(true),
631            ..Style::default()
632        };
633        let ansi_difference = style_a.compare_ansi(&style_b);
634        assert_eq!(
635            ansi_difference,
636            format!(
637                "{reset}{italic}",
638                reset = Ansi::RESET,
639                italic = Ansi::ITALIC
640            )
641        )
642    }
643    #[test]
644    fn ansi_difference_shouldnt_reset() {
645        let style_a = Style {
646            bold: Some(true),
647            ..Style::default()
648        };
649        let style_b = Style {
650            bold: Some(true),
651            italic: Some(true),
652            ..Style::default()
653        };
654        let ansi_difference = style_a.compare_ansi(&style_b);
655        assert_eq!(ansi_difference, Ansi::ITALIC)
656    }
657
658    #[test]
659    fn test_from_code() {
660        assert_eq!(
661            ChatFormatting::from_code('a').unwrap(),
662            ChatFormatting::Green
663        );
664    }
665
666    #[test]
667    fn test_apply_formatting() {
668        let mut style = Style::default();
669        style.apply_formatting(&ChatFormatting::Bold);
670        style.apply_formatting(&ChatFormatting::Red);
671        assert_eq!(style.color, Some(TextColor::from_rgb(16733525)));
672    }
673}