Skip to main content

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        use crate::get_in_compound;
589
590        let color: Option<TextColor> = compound
591            .string("color")
592            .and_then(|v| TextColor::parse(&v.to_str()));
593        let shadow_color = get_in_compound(&compound, "shadow_color").ok();
594        let bold = get_in_compound(&compound, "bold").ok();
595        let italic = get_in_compound(&compound, "italic").ok();
596        let underlined = get_in_compound(&compound, "underlined").ok();
597        let strikethrough = get_in_compound(&compound, "strikethrough").ok();
598        let obfuscated = get_in_compound(&compound, "obfuscated").ok();
599        let click_event = get_in_compound(&compound, "click_event").ok();
600        // TODO
601        // let hover_event = get_in_compound(&compound, "hover_event")?;
602        let insertion = get_in_compound(&compound, "insertion").ok();
603        let font = get_in_compound(&compound, "font").ok();
604        Ok(Style {
605            color,
606            shadow_color,
607            bold,
608            italic,
609            underlined,
610            strikethrough,
611            obfuscated,
612            click_event,
613            hover_event: None,
614            insertion,
615            font,
616        })
617    }
618}
619
620#[cfg(test)]
621mod tests {
622    use super::*;
623
624    #[test]
625    fn text_color_named_colors() {
626        assert_eq!(TextColor::parse("red").unwrap().value, 16733525);
627    }
628    #[test]
629    fn text_color_hex_colors() {
630        assert_eq!(TextColor::parse("#a1b2c3").unwrap().value, 10597059);
631    }
632
633    #[test]
634    fn ansi_difference_should_reset() {
635        let style_a = Style {
636            bold: Some(true),
637            italic: Some(true),
638            ..Style::default()
639        };
640        let style_b = Style {
641            bold: Some(false),
642            italic: Some(true),
643            ..Style::default()
644        };
645        let ansi_difference = style_a.compare_ansi(&style_b);
646        assert_eq!(
647            ansi_difference,
648            format!(
649                "{reset}{italic}",
650                reset = Ansi::RESET,
651                italic = Ansi::ITALIC
652            )
653        )
654    }
655    #[test]
656    fn ansi_difference_shouldnt_reset() {
657        let style_a = Style {
658            bold: Some(true),
659            ..Style::default()
660        };
661        let style_b = Style {
662            bold: Some(true),
663            italic: Some(true),
664            ..Style::default()
665        };
666        let ansi_difference = style_a.compare_ansi(&style_b);
667        assert_eq!(ansi_difference, Ansi::ITALIC)
668    }
669
670    #[test]
671    fn test_from_code() {
672        assert_eq!(
673            ChatFormatting::from_code('a').unwrap(),
674            ChatFormatting::Green
675        );
676    }
677
678    #[test]
679    fn test_apply_formatting() {
680        let mut style = Style::default();
681        style.apply_formatting(&ChatFormatting::Bold);
682        style.apply_formatting(&ChatFormatting::Red);
683        assert_eq!(style.color, Some(TextColor::from_rgb(16733525)));
684    }
685}