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