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        Self::FORMATTERS
205            .iter()
206            .find(|&formatter| formatter.name() == name)
207    }
208
209    pub fn code(&self) -> char {
210        match self {
211            Self::Black => '0',
212            Self::DarkBlue => '1',
213            Self::DarkGreen => '2',
214            Self::DarkAqua => '3',
215            Self::DarkRed => '4',
216            Self::DarkPurple => '5',
217            Self::Gold => '6',
218            Self::Gray => '7',
219            Self::DarkGray => '8',
220            Self::Blue => '9',
221            Self::Green => 'a',
222            Self::Aqua => 'b',
223            Self::Red => 'c',
224            Self::LightPurple => 'd',
225            Self::Yellow => 'e',
226            Self::White => 'f',
227            Self::Obfuscated => 'k',
228            Self::Strikethrough => 'm',
229            Self::Bold => 'l',
230            Self::Underline => 'n',
231            Self::Italic => 'o',
232            Self::Reset => 'r',
233        }
234    }
235
236    pub fn from_code(code: char) -> Option<Self> {
237        Some(match code {
238            '0' => Self::Black,
239            '1' => Self::DarkBlue,
240            '2' => Self::DarkGreen,
241            '3' => Self::DarkAqua,
242            '4' => Self::DarkRed,
243            '5' => Self::DarkPurple,
244            '6' => Self::Gold,
245            '7' => Self::Gray,
246            '8' => Self::DarkGray,
247            '9' => Self::Blue,
248            'a' => Self::Green,
249            'b' => Self::Aqua,
250            'c' => Self::Red,
251            'd' => Self::LightPurple,
252            'e' => Self::Yellow,
253            'f' => Self::White,
254            'k' => Self::Obfuscated,
255            'm' => Self::Strikethrough,
256            'l' => Self::Bold,
257            'n' => Self::Underline,
258            'o' => Self::Italic,
259            'r' => Self::Reset,
260            _ => return None,
261        })
262    }
263
264    pub fn is_format(&self) -> bool {
265        matches!(
266            self,
267            Self::Obfuscated
268                | Self::Strikethrough
269                | Self::Bold
270                | Self::Underline
271                | Self::Italic
272                | Self::Reset
273        )
274    }
275
276    pub fn color(&self) -> Option<u32> {
277        Some(match self {
278            Self::Black => 0,
279            Self::DarkBlue => 170,
280            Self::DarkGreen => 43520,
281            Self::DarkAqua => 43690,
282            Self::DarkRed => 11141120,
283            Self::DarkPurple => 11141290,
284            Self::Gold => 16755200,
285            Self::Gray => 11184810,
286            Self::DarkGray => 5592405,
287            Self::Blue => 5592575,
288            Self::Green => 5635925,
289            Self::Aqua => 5636095,
290            Self::Red => 16733525,
291            Self::LightPurple => 16733695,
292            Self::Yellow => 16777045,
293            Self::White => 16777215,
294            _ => return None,
295        })
296    }
297}
298
299// from ChatFormatting to TextColor
300impl TryFrom<ChatFormatting> for TextColor {
301    type Error = String;
302
303    fn try_from(formatter: ChatFormatting) -> Result<Self, Self::Error> {
304        if formatter.is_format() {
305            return Err(format!("{} is not a color", formatter.name()));
306        }
307        let color = formatter.color().unwrap_or(0);
308        Ok(Self::new(color, Some(formatter.name().to_owned())))
309    }
310}
311
312macro_rules! define_style_struct {
313    ($($(#[$doc:meta])* $field:ident : $type:ty),* $(,)?) => {
314        #[derive(Clone, Debug, Default, PartialEq, serde::Serialize)]
315        #[non_exhaustive]
316        pub struct Style {
317            $(
318                #[serde(skip_serializing_if = "Option::is_none")]
319                $(#[$doc])*
320                pub $field: Option<$type>,
321            )*
322        }
323
324        impl Style {
325            $(
326                pub fn $field(mut self, value: impl Into<Option<$type>>) -> Self {
327                    self.$field = value.into();
328                    self
329                }
330            )*
331
332            pub fn serialize_map<S>(&self, state: &mut S::SerializeMap) -> Result<(), S::Error>
333            where
334                S: serde::Serializer,
335            {
336                $(
337                    if let Some(value) = &self.$field {
338                        state.serialize_entry(stringify!($field), value)?;
339                    }
340                )*
341                Ok(())
342            }
343
344            /// Apply another style to this one
345            pub fn apply(&mut self, style: &Style) {
346                $(
347                    if let Some(value) = &style.$field {
348                        self.$field = Some(value.clone());
349                    }
350                )*
351            }
352        }
353
354        #[cfg(feature = "simdnbt")]
355        impl simdnbt::Serialize for Style {
356            fn to_compound(self) -> NbtCompound {
357                let mut compound = NbtCompound::new();
358
359                $(
360                    if let Some(value) = self.$field {
361                        compound.insert(stringify!($field), value);
362                    }
363                )*
364
365                compound
366            }
367        }
368    };
369}
370
371define_style_struct! {
372    color: TextColor,
373    shadow_color: u32,
374    bold: bool,
375    italic: bool,
376    underlined: bool,
377    strikethrough: bool,
378    obfuscated: bool,
379    click_event: ClickEvent,
380    hover_event: HoverEvent,
381    insertion: String,
382    /// Represented as an `Identifier`.
383    font: String,
384}
385
386impl Style {
387    pub fn new() -> Self {
388        Self::default()
389    }
390
391    pub fn empty() -> Self {
392        Self::default()
393    }
394
395    pub fn deserialize(json: &Value) -> Style {
396        let Some(j) = json.as_object() else {
397            return Style::default();
398        };
399
400        Style {
401            color: j
402                .get("color")
403                .and_then(|v| v.as_str())
404                .and_then(TextColor::parse),
405            shadow_color: j
406                .get("shadow_color")
407                .and_then(|v| v.as_u64())
408                .map(|v| v as u32),
409            bold: j.get("bold").and_then(|v| v.as_bool()),
410            italic: j.get("italic").and_then(|v| v.as_bool()),
411            underlined: j.get("underlined").and_then(|v| v.as_bool()),
412            strikethrough: j.get("strikethrough").and_then(|v| v.as_bool()),
413            obfuscated: j.get("obfuscated").and_then(|v| v.as_bool()),
414            // TODO: impl deserialize functions for click_event and hover_event
415            click_event: Default::default(),
416            hover_event: Default::default(),
417            insertion: j
418                .get("insertion")
419                .and_then(|v| v.as_str())
420                .map(|s| s.to_owned()),
421            font: j.get("font").and_then(|v| v.as_str()).map(|s| s.to_owned()),
422        }
423    }
424
425    /// Check if a style has no attributes set
426    pub fn is_empty(&self) -> bool {
427        self.color.is_none()
428            && self.bold.is_none()
429            && self.italic.is_none()
430            && self.underlined.is_none()
431            && self.strikethrough.is_none()
432            && self.obfuscated.is_none()
433    }
434
435    /// find the necessary ansi code to get from this style to another
436    pub fn compare_ansi(&self, after: &Style) -> String {
437        let should_reset =
438            // if any property used to be true and now it's not, reset
439            (self.bold.unwrap_or_default() && !after.bold.unwrap_or_default()) ||
440            (self.italic.unwrap_or_default() && !after.italic.unwrap_or_default()) ||
441            (self.underlined.unwrap_or_default() && !after.underlined.unwrap_or_default()) ||
442            (self.strikethrough.unwrap_or_default() && !after.strikethrough.unwrap_or_default()) ||
443            (self.obfuscated.unwrap_or_default() && !after.obfuscated.unwrap_or_default());
444
445        let mut ansi_codes = String::new();
446
447        let empty_style = Style::empty();
448
449        let before = if should_reset {
450            ansi_codes.push_str(Ansi::RESET);
451            &empty_style
452        } else {
453            self
454        };
455
456        // if any property was false/default and now it's true, add the right ansi codes
457        if !before.bold.unwrap_or_default() && after.bold.unwrap_or_default() {
458            ansi_codes.push_str(Ansi::BOLD);
459        }
460        if !before.italic.unwrap_or_default() && after.italic.unwrap_or_default() {
461            ansi_codes.push_str(Ansi::ITALIC);
462        }
463        if !before.underlined.unwrap_or_default() && after.underlined.unwrap_or_default() {
464            ansi_codes.push_str(Ansi::UNDERLINED);
465        }
466        if !before.strikethrough.unwrap_or_default() && after.strikethrough.unwrap_or_default() {
467            ansi_codes.push_str(Ansi::STRIKETHROUGH);
468        }
469        if !before.obfuscated.unwrap_or_default() && after.obfuscated.unwrap_or_default() {
470            ansi_codes.push_str(Ansi::OBFUSCATED);
471        }
472
473        // if the new color is different and not none, set color
474        let color_changed = {
475            if before.color.is_none() && after.color.is_some() {
476                true
477            } else if let Some(before_color) = &before.color
478                && let Some(after_color) = &after.color
479            {
480                before_color.value != after_color.value
481            } else {
482                false
483            }
484        };
485
486        if color_changed {
487            let after_color = after.color.as_ref().unwrap();
488            ansi_codes.push_str(&Ansi::rgb(after_color.value));
489        }
490
491        ansi_codes
492    }
493
494    /// Returns a new style that is a merge of self and other.
495    /// For any field that `other` does not specify (is None), self's value is
496    /// used.
497    pub fn merged_with(&self, other: &Style) -> Style {
498        Style {
499            color: other.color.clone().or(self.color.clone()),
500            shadow_color: other.shadow_color.or(self.shadow_color),
501            bold: other.bold.or(self.bold),
502            italic: other.italic.or(self.italic),
503            underlined: other.underlined.or(self.underlined),
504            strikethrough: other.strikethrough.or(self.strikethrough),
505            obfuscated: other.obfuscated.or(self.obfuscated),
506            click_event: other.click_event.clone().or(self.click_event.clone()),
507            hover_event: other.hover_event.clone().or(self.hover_event.clone()),
508            insertion: other.insertion.clone().or(self.insertion.clone()),
509            font: other.font.clone().or(self.font.clone()),
510        }
511    }
512
513    /// Apply a ChatFormatting to this style
514    pub fn apply_formatting(&mut self, formatting: &ChatFormatting) {
515        match *formatting {
516            ChatFormatting::Bold => self.bold = Some(true),
517            ChatFormatting::Italic => self.italic = Some(true),
518            ChatFormatting::Underline => self.underlined = Some(true),
519            ChatFormatting::Strikethrough => self.strikethrough = Some(true),
520            ChatFormatting::Obfuscated => self.obfuscated = Some(true),
521            ChatFormatting::Reset => {
522                self.color = None;
523                self.bold = None;
524                self.italic = None;
525                self.underlined = None;
526                self.strikethrough = None;
527                self.obfuscated = None;
528            }
529            formatter => {
530                // if it's a color, set it
531                if let Some(color) = formatter.color() {
532                    self.color = Some(TextColor::from_rgb(color));
533                }
534            }
535        }
536    }
537
538    pub fn get_html_style(&self) -> String {
539        let mut style = String::new();
540        if let Some(color) = &self.color {
541            style.push_str(&format!("color:{};", color.format_value()));
542        }
543        if let Some(bold) = self.bold {
544            style.push_str(&format!(
545                "font-weight:{};",
546                if bold { "bold" } else { "normal" }
547            ));
548        }
549        if let Some(italic) = self.italic {
550            style.push_str(&format!(
551                "font-style:{};",
552                if italic { "italic" } else { "normal" }
553            ));
554        }
555        if let Some(underlined) = self.underlined {
556            style.push_str(&format!(
557                "text-decoration:{};",
558                if underlined { "underline" } else { "none" }
559            ));
560        }
561        if let Some(strikethrough) = self.strikethrough {
562            style.push_str(&format!(
563                "text-decoration:{};",
564                if strikethrough {
565                    "line-through"
566                } else {
567                    "none"
568                }
569            ));
570        }
571        if let Some(obfuscated) = self.obfuscated
572            && obfuscated
573        {
574            style.push_str("filter:blur(2px);");
575        }
576
577        style
578    }
579}
580
581#[cfg(feature = "simdnbt")]
582impl simdnbt::Deserialize for Style {
583    fn from_compound(
584        compound: simdnbt::borrow::NbtCompound,
585    ) -> Result<Self, simdnbt::DeserializeError> {
586        use crate::get_in_compound;
587
588        let color: Option<TextColor> = compound
589            .string("color")
590            .and_then(|v| TextColor::parse(&v.to_str()));
591        let shadow_color = get_in_compound(&compound, "shadow_color").ok();
592        let bold = get_in_compound(&compound, "bold").ok();
593        let italic = get_in_compound(&compound, "italic").ok();
594        let underlined = get_in_compound(&compound, "underlined").ok();
595        let strikethrough = get_in_compound(&compound, "strikethrough").ok();
596        let obfuscated = get_in_compound(&compound, "obfuscated").ok();
597        let click_event = get_in_compound(&compound, "click_event").ok();
598        // TODO
599        // let hover_event = get_in_compound(&compound, "hover_event")?;
600        let insertion = get_in_compound(&compound, "insertion").ok();
601        let font = get_in_compound(&compound, "font").ok();
602        Ok(Style {
603            color,
604            shadow_color,
605            bold,
606            italic,
607            underlined,
608            strikethrough,
609            obfuscated,
610            click_event,
611            hover_event: None,
612            insertion,
613            font,
614        })
615    }
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621
622    #[test]
623    fn text_color_named_colors() {
624        assert_eq!(TextColor::parse("red").unwrap().value, 16733525);
625    }
626    #[test]
627    fn text_color_hex_colors() {
628        assert_eq!(TextColor::parse("#a1b2c3").unwrap().value, 10597059);
629    }
630
631    #[test]
632    fn ansi_difference_should_reset() {
633        let style_a = Style {
634            bold: Some(true),
635            italic: Some(true),
636            ..Style::default()
637        };
638        let style_b = Style {
639            bold: Some(false),
640            italic: Some(true),
641            ..Style::default()
642        };
643        let ansi_difference = style_a.compare_ansi(&style_b);
644        assert_eq!(
645            ansi_difference,
646            format!(
647                "{reset}{italic}",
648                reset = Ansi::RESET,
649                italic = Ansi::ITALIC
650            )
651        )
652    }
653    #[test]
654    fn ansi_difference_shouldnt_reset() {
655        let style_a = Style {
656            bold: Some(true),
657            ..Style::default()
658        };
659        let style_b = Style {
660            bold: Some(true),
661            italic: Some(true),
662            ..Style::default()
663        };
664        let ansi_difference = style_a.compare_ansi(&style_b);
665        assert_eq!(ansi_difference, Ansi::ITALIC)
666    }
667
668    #[test]
669    fn test_from_code() {
670        assert_eq!(
671            ChatFormatting::from_code('a').unwrap(),
672            ChatFormatting::Green
673        );
674    }
675
676    #[test]
677    fn test_apply_formatting() {
678        let mut style = Style::default();
679        style.apply_formatting(&ChatFormatting::Bold);
680        style.apply_formatting(&ChatFormatting::Red);
681        assert_eq!(style.color, Some(TextColor::from_rgb(16733525)));
682    }
683}