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