use std::{collections::HashMap, fmt, sync::LazyLock}; #[cfg(feature = "azalea-buf")] use azalea_buf::AzBuf; use serde::{Serialize, Serializer, ser::SerializeMap}; use serde_json::Value; #[cfg(feature = "simdnbt")] use simdnbt::owned::{NbtCompound, NbtTag}; use crate::{click_event::ClickEvent, hover_event::HoverEvent}; #[derive(Clone, Debug, Eq, Hash, PartialEq)] pub struct TextColor { pub value: u32, pub name: Option, } impl Serialize for TextColor { fn serialize(&self, serializer: S) -> Result where S: Serializer, { serializer.serialize_str(&self.serialize()) } } #[cfg(feature = "simdnbt")] impl simdnbt::ToNbtTag for TextColor { fn to_nbt_tag(self) -> simdnbt::owned::NbtTag { NbtTag::String(self.serialize().into()) } } impl TextColor { fn new(value: u32, name: Option) -> Self { Self { value, name } } fn serialize(&self) -> String { if let Some(name) = &self.name { name.to_ascii_lowercase() } else { self.format_value() } } pub fn format_value(&self) -> String { format!("#{:06X}", self.value) } /// Parse a text component in the same way that Minecraft does. /// /// This supports named colors and hex codes. pub fn parse(value: &str) -> Option { if value.starts_with('#') { let n = value.chars().skip(1).collect::(); let n = u32::from_str_radix(&n, 16).ok()?; return Some(TextColor::from_rgb(n)); } let color_option = NAMED_COLORS.get(&value.to_ascii_lowercase()); if let Some(color) = color_option { return Some(color.clone()); } None } fn from_rgb(value: u32) -> TextColor { TextColor { value, name: None } } } impl fmt::Display for TextColor { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}", self.serialize()) } } static LEGACY_FORMAT_TO_COLOR: LazyLock> = LazyLock::new(|| { let mut legacy_format_to_color = HashMap::new(); for formatter in &ChatFormatting::FORMATTERS { if formatter.is_format() || *formatter == ChatFormatting::Reset { continue; } legacy_format_to_color.insert( formatter, TextColor { value: formatter.color().unwrap(), name: Some(formatter.name().to_owned()), }, ); } legacy_format_to_color }); static NAMED_COLORS: LazyLock> = LazyLock::new(|| { let mut named_colors = HashMap::new(); for color in LEGACY_FORMAT_TO_COLOR.values() { named_colors.insert(color.name.clone().unwrap(), color.clone()); } named_colors }); pub struct Ansi; impl Ansi { pub const BOLD: &'static str = "\u{1b}[1m"; pub const ITALIC: &'static str = "\u{1b}[3m"; pub const UNDERLINED: &'static str = "\u{1b}[4m"; pub const STRIKETHROUGH: &'static str = "\u{1b}[9m"; // "Conceal or hide" pub const OBFUSCATED: &'static str = "\u{1b}[8m"; pub const RESET: &'static str = "\u{1b}[m"; pub fn rgb(value: u32) -> String { format!( "\u{1b}[38;2;{};{};{}m", (value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF ) } } #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] #[cfg_attr(feature = "azalea-buf", derive(AzBuf))] pub enum ChatFormatting { Black, DarkBlue, DarkGreen, DarkAqua, DarkRed, DarkPurple, Gold, Gray, DarkGray, Blue, Green, Aqua, Red, LightPurple, Yellow, White, Obfuscated, Strikethrough, Bold, Underline, Italic, Reset, } impl ChatFormatting { pub const FORMATTERS: [Self; 22] = [ Self::Black, Self::DarkBlue, Self::DarkGreen, Self::DarkAqua, Self::DarkRed, Self::DarkPurple, Self::Gold, Self::Gray, Self::DarkGray, Self::Blue, Self::Green, Self::Aqua, Self::Red, Self::LightPurple, Self::Yellow, Self::White, Self::Obfuscated, Self::Strikethrough, Self::Bold, Self::Underline, Self::Italic, Self::Reset, ]; pub fn name(&self) -> &'static str { match self { Self::Black => "black", Self::DarkBlue => "dark_blue", Self::DarkGreen => "dark_green", Self::DarkAqua => "dark_aqua", Self::DarkRed => "dark_red", Self::DarkPurple => "dark_purple", Self::Gold => "gold", Self::Gray => "gray", Self::DarkGray => "dark_gray", Self::Blue => "blue", Self::Green => "green", Self::Aqua => "aqua", Self::Red => "red", Self::LightPurple => "light_purple", Self::Yellow => "yellow", Self::White => "white", Self::Obfuscated => "obfuscated", Self::Strikethrough => "strikethrough", Self::Bold => "bold", Self::Underline => "underline", Self::Italic => "italic", Self::Reset => "reset", } } pub fn from_name(name: &str) -> Option<&'static Self> { Self::FORMATTERS .iter() .find(|&formatter| formatter.name() == name) } pub fn code(&self) -> char { match self { Self::Black => '0', Self::DarkBlue => '1', Self::DarkGreen => '2', Self::DarkAqua => '3', Self::DarkRed => '4', Self::DarkPurple => '5', Self::Gold => '6', Self::Gray => '7', Self::DarkGray => '8', Self::Blue => '9', Self::Green => 'a', Self::Aqua => 'b', Self::Red => 'c', Self::LightPurple => 'd', Self::Yellow => 'e', Self::White => 'f', Self::Obfuscated => 'k', Self::Strikethrough => 'm', Self::Bold => 'l', Self::Underline => 'n', Self::Italic => 'o', Self::Reset => 'r', } } pub fn from_code(code: char) -> Option { Some(match code { '0' => Self::Black, '1' => Self::DarkBlue, '2' => Self::DarkGreen, '3' => Self::DarkAqua, '4' => Self::DarkRed, '5' => Self::DarkPurple, '6' => Self::Gold, '7' => Self::Gray, '8' => Self::DarkGray, '9' => Self::Blue, 'a' => Self::Green, 'b' => Self::Aqua, 'c' => Self::Red, 'd' => Self::LightPurple, 'e' => Self::Yellow, 'f' => Self::White, 'k' => Self::Obfuscated, 'm' => Self::Strikethrough, 'l' => Self::Bold, 'n' => Self::Underline, 'o' => Self::Italic, 'r' => Self::Reset, _ => return None, }) } pub fn is_format(&self) -> bool { matches!( self, Self::Obfuscated | Self::Strikethrough | Self::Bold | Self::Underline | Self::Italic | Self::Reset ) } pub fn color(&self) -> Option { Some(match self { Self::Black => 0, Self::DarkBlue => 170, Self::DarkGreen => 43520, Self::DarkAqua => 43690, Self::DarkRed => 11141120, Self::DarkPurple => 11141290, Self::Gold => 16755200, Self::Gray => 11184810, Self::DarkGray => 5592405, Self::Blue => 5592575, Self::Green => 5635925, Self::Aqua => 5636095, Self::Red => 16733525, Self::LightPurple => 16733695, Self::Yellow => 16777045, Self::White => 16777215, _ => return None, }) } } // from ChatFormatting to TextColor impl TryFrom for TextColor { type Error = String; fn try_from(formatter: ChatFormatting) -> Result { if formatter.is_format() { return Err(format!("{} is not a color", formatter.name())); } let color = formatter.color().unwrap_or(0); Ok(Self::new(color, Some(formatter.name().to_owned()))) } } macro_rules! define_style_struct { ($($(#[$doc:meta])* $field:ident : $type:ty),* $(,)?) => { #[derive(Clone, Debug, Default, PartialEq, serde::Serialize)] #[non_exhaustive] pub struct Style { $( #[serde(skip_serializing_if = "Option::is_none")] $(#[$doc])* pub $field: Option<$type>, )* } impl Style { $( pub fn $field(mut self, value: impl Into>) -> Self { self.$field = value.into(); self } )* pub fn serialize_map(&self, state: &mut S::SerializeMap) -> Result<(), S::Error> where S: serde::Serializer, { $( if let Some(value) = &self.$field { state.serialize_entry(stringify!($field), value)?; } )* Ok(()) } /// Apply another style to this one pub fn apply(&mut self, style: &Style) { $( if let Some(value) = &style.$field { self.$field = Some(value.clone()); } )* } } #[cfg(feature = "simdnbt")] impl simdnbt::Serialize for Style { fn to_compound(self) -> NbtCompound { let mut compound = NbtCompound::new(); $( if let Some(value) = self.$field { compound.insert(stringify!($field), value); } )* compound } } }; } define_style_struct! { color: TextColor, shadow_color: u32, bold: bool, italic: bool, underlined: bool, strikethrough: bool, obfuscated: bool, click_event: ClickEvent, hover_event: HoverEvent, insertion: String, /// Represented as an `Identifier`. font: String, } impl Style { pub fn new() -> Self { Self::default() } pub fn empty() -> Self { Self::default() } pub fn deserialize(json: &Value) -> Style { let Some(j) = json.as_object() else { return Style::default(); }; Style { color: j .get("color") .and_then(|v| v.as_str()) .and_then(TextColor::parse), shadow_color: j .get("shadow_color") .and_then(|v| v.as_u64()) .map(|v| v as u32), bold: j.get("bold").and_then(|v| v.as_bool()), italic: j.get("italic").and_then(|v| v.as_bool()), underlined: j.get("underlined").and_then(|v| v.as_bool()), strikethrough: j.get("strikethrough").and_then(|v| v.as_bool()), obfuscated: j.get("obfuscated").and_then(|v| v.as_bool()), // TODO: impl deserialize functions for click_event and hover_event click_event: Default::default(), hover_event: Default::default(), insertion: j .get("insertion") .and_then(|v| v.as_str()) .map(|s| s.to_owned()), font: j.get("font").and_then(|v| v.as_str()).map(|s| s.to_owned()), } } /// Check if a style has no attributes set pub fn is_empty(&self) -> bool { self.color.is_none() && self.bold.is_none() && self.italic.is_none() && self.underlined.is_none() && self.strikethrough.is_none() && self.obfuscated.is_none() } /// find the necessary ansi code to get from this style to another pub fn compare_ansi(&self, after: &Style) -> String { let should_reset = // if any property used to be true and now it's not, reset (self.bold.unwrap_or_default() && !after.bold.unwrap_or_default()) || (self.italic.unwrap_or_default() && !after.italic.unwrap_or_default()) || (self.underlined.unwrap_or_default() && !after.underlined.unwrap_or_default()) || (self.strikethrough.unwrap_or_default() && !after.strikethrough.unwrap_or_default()) || (self.obfuscated.unwrap_or_default() && !after.obfuscated.unwrap_or_default()); let mut ansi_codes = String::new(); let empty_style = Style::empty(); let before = if should_reset { ansi_codes.push_str(Ansi::RESET); &empty_style } else { self }; // if any property was false/default and now it's true, add the right ansi codes if !before.bold.unwrap_or_default() && after.bold.unwrap_or_default() { ansi_codes.push_str(Ansi::BOLD); } if !before.italic.unwrap_or_default() && after.italic.unwrap_or_default() { ansi_codes.push_str(Ansi::ITALIC); } if !before.underlined.unwrap_or_default() && after.underlined.unwrap_or_default() { ansi_codes.push_str(Ansi::UNDERLINED); } if !before.strikethrough.unwrap_or_default() && after.strikethrough.unwrap_or_default() { ansi_codes.push_str(Ansi::STRIKETHROUGH); } if !before.obfuscated.unwrap_or_default() && after.obfuscated.unwrap_or_default() { ansi_codes.push_str(Ansi::OBFUSCATED); } // if the new color is different and not none, set color let color_changed = { if before.color.is_none() && after.color.is_some() { true } else if let Some(before_color) = &before.color && let Some(after_color) = &after.color { before_color.value != after_color.value } else { false } }; if color_changed { let after_color = after.color.as_ref().unwrap(); ansi_codes.push_str(&Ansi::rgb(after_color.value)); } ansi_codes } /// Returns a new style that is a merge of self and other. /// For any field that `other` does not specify (is None), self's value is /// used. pub fn merged_with(&self, other: &Style) -> Style { Style { color: other.color.clone().or(self.color.clone()), shadow_color: other.shadow_color.or(self.shadow_color), bold: other.bold.or(self.bold), italic: other.italic.or(self.italic), underlined: other.underlined.or(self.underlined), strikethrough: other.strikethrough.or(self.strikethrough), obfuscated: other.obfuscated.or(self.obfuscated), click_event: other.click_event.clone().or(self.click_event.clone()), hover_event: other.hover_event.clone().or(self.hover_event.clone()), insertion: other.insertion.clone().or(self.insertion.clone()), font: other.font.clone().or(self.font.clone()), } } /// Apply a ChatFormatting to this style pub fn apply_formatting(&mut self, formatting: &ChatFormatting) { match *formatting { ChatFormatting::Bold => self.bold = Some(true), ChatFormatting::Italic => self.italic = Some(true), ChatFormatting::Underline => self.underlined = Some(true), ChatFormatting::Strikethrough => self.strikethrough = Some(true), ChatFormatting::Obfuscated => self.obfuscated = Some(true), ChatFormatting::Reset => { self.color = None; self.bold = None; self.italic = None; self.underlined = None; self.strikethrough = None; self.obfuscated = None; } formatter => { // if it's a color, set it if let Some(color) = formatter.color() { self.color = Some(TextColor::from_rgb(color)); } } } } pub fn get_html_style(&self) -> String { let mut style = String::new(); if let Some(color) = &self.color { style.push_str(&format!("color:{};", color.format_value())); } if let Some(bold) = self.bold { style.push_str(&format!( "font-weight:{};", if bold { "bold" } else { "normal" } )); } if let Some(italic) = self.italic { style.push_str(&format!( "font-style:{};", if italic { "italic" } else { "normal" } )); } if let Some(underlined) = self.underlined { style.push_str(&format!( "text-decoration:{};", if underlined { "underline" } else { "none" } )); } if let Some(strikethrough) = self.strikethrough { style.push_str(&format!( "text-decoration:{};", if strikethrough { "line-through" } else { "none" } )); } if let Some(obfuscated) = self.obfuscated && obfuscated { style.push_str("filter:blur(2px);"); } style } } #[cfg(feature = "simdnbt")] impl simdnbt::Deserialize for Style { fn from_compound( compound: simdnbt::borrow::NbtCompound, ) -> Result { use crate::get_in_compound; let color: Option = compound .string("color") .and_then(|v| TextColor::parse(&v.to_str())); let shadow_color = get_in_compound(&compound, "shadow_color").ok(); let bold = get_in_compound(&compound, "bold").ok(); let italic = get_in_compound(&compound, "italic").ok(); let underlined = get_in_compound(&compound, "underlined").ok(); let strikethrough = get_in_compound(&compound, "strikethrough").ok(); let obfuscated = get_in_compound(&compound, "obfuscated").ok(); let click_event = get_in_compound(&compound, "click_event").ok(); // TODO // let hover_event = get_in_compound(&compound, "hover_event")?; let insertion = get_in_compound(&compound, "insertion").ok(); let font = get_in_compound(&compound, "font").ok(); Ok(Style { color, shadow_color, bold, italic, underlined, strikethrough, obfuscated, click_event, hover_event: None, insertion, font, }) } } #[cfg(test)] mod tests { use super::*; #[test] fn text_color_named_colors() { assert_eq!(TextColor::parse("red").unwrap().value, 16733525); } #[test] fn text_color_hex_colors() { assert_eq!(TextColor::parse("#a1b2c3").unwrap().value, 10597059); } #[test] fn ansi_difference_should_reset() { let style_a = Style { bold: Some(true), italic: Some(true), ..Style::default() }; let style_b = Style { bold: Some(false), italic: Some(true), ..Style::default() }; let ansi_difference = style_a.compare_ansi(&style_b); assert_eq!( ansi_difference, format!( "{reset}{italic}", reset = Ansi::RESET, italic = Ansi::ITALIC ) ) } #[test] fn ansi_difference_shouldnt_reset() { let style_a = Style { bold: Some(true), ..Style::default() }; let style_b = Style { bold: Some(true), italic: Some(true), ..Style::default() }; let ansi_difference = style_a.compare_ansi(&style_b); assert_eq!(ansi_difference, Ansi::ITALIC) } #[test] fn test_from_code() { assert_eq!( ChatFormatting::from_code('a').unwrap(), ChatFormatting::Green ); } #[test] fn test_apply_formatting() { let mut style = Style::default(); style.apply_formatting(&ChatFormatting::Bold); style.apply_formatting(&ChatFormatting::Red); assert_eq!(style.color, Some(TextColor::from_rgb(16733525))); } }