diff options
| author | mat <github@matdoes.dev> | 2021-12-15 23:10:55 -0600 |
|---|---|---|
| committer | mat <github@matdoes.dev> | 2021-12-15 23:10:55 -0600 |
| commit | 9642558f8f8d983a7087f15d68be8cf07a85f0c2 (patch) | |
| tree | 5f0a967f005cd5db510a13ab290c8ad6669b25aa /azalea-chat/src | |
| parent | 72aefe871ca4983431b1a0b707b472e73ffea836 (diff) | |
| download | azalea-drasl-9642558f8f8d983a7087f15d68be8cf07a85f0c2.tar.xz | |
azalea
Diffstat (limited to 'azalea-chat/src')
| -rw-r--r-- | azalea-chat/src/base_component.rs | 23 | ||||
| -rw-r--r-- | azalea-chat/src/component.rs | 262 | ||||
| -rw-r--r-- | azalea-chat/src/events.rs | 26 | ||||
| -rw-r--r-- | azalea-chat/src/lib.rs | 11 | ||||
| -rw-r--r-- | azalea-chat/src/style.rs | 478 | ||||
| -rw-r--r-- | azalea-chat/src/text_component.rs | 122 | ||||
| -rw-r--r-- | azalea-chat/src/translatable_component.rs | 24 |
7 files changed, 946 insertions, 0 deletions
diff --git a/azalea-chat/src/base_component.rs b/azalea-chat/src/base_component.rs new file mode 100644 index 00000000..fa39a11c --- /dev/null +++ b/azalea-chat/src/base_component.rs @@ -0,0 +1,23 @@ +use crate::{component::Component, style::Style}; + +#[derive(Clone, Debug)] +pub struct BaseComponent { + // implements mutablecomponent + pub siblings: Vec<Component>, + pub style: Style, +} + +impl BaseComponent { + pub fn new() -> Self { + Self { + siblings: Vec::new(), + style: Style::default(), + } + } +} + +impl Default for BaseComponent { + fn default() -> Self { + Self::new() + } +} diff --git a/azalea-chat/src/component.rs b/azalea-chat/src/component.rs new file mode 100644 index 00000000..1ed1f836 --- /dev/null +++ b/azalea-chat/src/component.rs @@ -0,0 +1,262 @@ +use serde::{de, Deserialize, Deserializer}; + +use crate::{ + base_component::BaseComponent, + style::{ChatFormatting, Style}, + text_component::TextComponent, + translatable_component::{StringOrComponent, TranslatableComponent}, +}; + +#[derive(Clone, Debug)] +pub enum Component { + Text(TextComponent), + Translatable(TranslatableComponent), +} + +lazy_static! { + pub static ref DEFAULT_STYLE: Style = Style { + color: Some(ChatFormatting::WHITE.try_into().unwrap()), + ..Style::default() + }; +} + +/// A chat component +impl Component { + // TODO: is it possible to use a macro so this doesn't have to be duplicated? + + pub fn get_base_mut(&mut self) -> &mut BaseComponent { + match self { + Self::Text(c) => &mut c.base, + Self::Translatable(c) => &mut c.base, + } + } + + pub fn get_base(&self) -> &BaseComponent { + match self { + Self::Text(c) => &c.base, + Self::Translatable(c) => &c.base, + } + } + + /// Add a component as a sibling of this one + fn append(&mut self, sibling: Component) { + self.get_base_mut().siblings.push(sibling); + } + + /// Get the "separator" component from the json + fn parse_separator(json: &serde_json::Value) -> Result<Option<Component>, serde_json::Error> { + if json.get("separator").is_some() { + return Ok(Some(Component::deserialize( + json.get("separator").unwrap(), + )?)); + } + Ok(None) + } + + /// Convert this component into an ansi string + pub fn to_ansi(&self, default_style: Option<&Style>) -> String { + // default the default_style to white if it's not set + let default_style: &Style = default_style.unwrap_or(&DEFAULT_STYLE); + + // this contains the final string will all the ansi escape codes + let mut built_string = String::new(); + // this style will update as we visit components + let mut running_style = Style::default(); + + for component in self.clone().into_iter() { + let component_text = match &component { + Self::Text(c) => &c.text, + Self::Translatable(c) => &c.key, + }; + let component_style = &component.get_base().style; + + let ansi_text = running_style.compare_ansi(component_style, default_style); + built_string.push_str(&ansi_text); + built_string.push_str(component_text); + + running_style.apply(component_style); + } + + if !running_style.is_empty() { + built_string.push_str("\u{1b}[m"); + } + + built_string + } +} + +impl IntoIterator for Component { + /// Recursively call the function for every component in this component + fn into_iter(self) -> Self::IntoIter { + let base = self.get_base(); + let siblings = base.siblings.clone(); + let mut v: Vec<Component> = Vec::with_capacity(siblings.len() + 1); + v.push(self); + for sibling in siblings { + v.extend(sibling.into_iter()); + } + + v.into_iter() + } + + type Item = Component; + type IntoIter = std::vec::IntoIter<Self::Item>; +} + +impl<'de> Deserialize<'de> for Component { + fn deserialize<D>(de: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + let json: serde_json::Value = serde::Deserialize::deserialize(de)?; + + // we create a component that we might add siblings to + let mut component: Component; + + // if it's primitive, make it a text component + if !json.is_array() && !json.is_object() { + return Ok(Component::Text(TextComponent::new( + json.as_str().unwrap_or("").to_string(), + ))); + } + // if it's an object, do things with { text } and stuff + else if json.is_object() { + if json.get("text").is_some() { + let text = json.get("text").unwrap().as_str().unwrap_or("").to_string(); + component = Component::Text(TextComponent::new(text)); + } else if json.get("translate").is_some() { + let translate = json.get("translate").unwrap().to_string(); + if json.get("with").is_some() { + let with = json.get("with").unwrap().as_array().unwrap(); + let mut with_array = Vec::with_capacity(with.len()); + for item in with { + // if it's a string component with no styling and no siblings, just add a string to with_array + // otherwise add the component to the array + let c = Component::deserialize(item).map_err(de::Error::custom)?; + if let Component::Text(text_component) = c { + if text_component.base.siblings.is_empty() + && text_component.base.style.is_empty() + { + with_array.push(StringOrComponent::String(text_component.text)); + break; + } + } + with_array.push(StringOrComponent::Component( + Component::deserialize(item).map_err(de::Error::custom)?, + )); + } + component = + Component::Translatable(TranslatableComponent::new(translate, with_array)); + } else { + // if it doesn't have a "with", just have the with_array be empty + component = + Component::Translatable(TranslatableComponent::new(translate, Vec::new())); + } + } else if json.get("score").is_some() { + // object = GsonHelper.getAsJsonObject(jsonObject, "score"); + let score_json = json.get("score").unwrap(); + // if (!object.has("name") || !object.has("objective")) throw new JsonParseException("A score component needs a least a name and an objective"); + // ScoreComponent scoreComponent = new ScoreComponent(GsonHelper.getAsString((JsonObject)object, "name"), GsonHelper.getAsString((JsonObject)object, "objective")); + if score_json.get("name").is_none() || score_json.get("objective").is_none() { + return Err(de::Error::missing_field( + "A score component needs at least a name and an objective", + )); + } + // TODO + return Err(de::Error::custom( + "score text components aren't yet supported", + )); + // component = ScoreComponent + } else if json.get("selector").is_some() { + // } else if (jsonObject.has("selector")) { + // object = this.parseSeparator(type, jsonDeserializationContext, jsonObject); + // SelectorComponent selectorComponent = new SelectorComponent(GsonHelper.getAsString(jsonObject, "selector"), (Optional<Component>)object); + + return Err(de::Error::custom( + "selector text components aren't yet supported", + )); + // } else if (jsonObject.has("keybind")) { + // KeybindComponent keybindComponent = new KeybindComponent(GsonHelper.getAsString(jsonObject, "keybind")); + } else if json.get("keybind").is_some() { + return Err(de::Error::custom( + "keybind text components aren't yet supported", + )); + } else { + // } else { + // if (!jsonObject.has("nbt")) throw new JsonParseException("Don't know how to turn " + jsonElement + " into a Component"); + if json.get("nbt").is_none() { + return Err(de::Error::custom( + format!("Don't know how to turn {} into a Component", json).as_str(), + )); + } + // object = GsonHelper.getAsString(jsonObject, "nbt"); + let _nbt = json.get("nbt").unwrap().to_string(); + // Optional<Component> optional = this.parseSeparator(type, jsonDeserializationContext, jsonObject); + let _separator = Component::parse_separator(&json).map_err(de::Error::custom)?; + + let _interpret = match json.get("interpret") { + Some(v) => v.as_bool().ok_or(Some(false)).unwrap(), + None => false, + }; + // boolean bl = GsonHelper.getAsBoolean(jsonObject, "interpret", false); + // if (jsonObject.has("block")) { + if json.get("block").is_some() {} + return Err(de::Error::custom( + "nbt text components aren't yet supported", + )); + // NbtComponent.BlockNbtComponent blockNbtComponent = new NbtComponent.BlockNbtComponent((String)object, bl, GsonHelper.getAsString(jsonObject, "block"), optional); + // } else if (jsonObject.has("entity")) { + // NbtComponent.EntityNbtComponent entityNbtComponent = new NbtComponent.EntityNbtComponent((String)object, bl, GsonHelper.getAsString(jsonObject, "entity"), optional); + // } else { + // if (!jsonObject.has("storage")) throw new JsonParseException("Don't know how to turn " + jsonElement + " into a Component"); + // NbtComponent.StorageNbtComponent storageNbtComponent = new NbtComponent.StorageNbtComponent((String)object, bl, new ResourceLocation(GsonHelper.getAsString(jsonObject, "storage")), optional); + // } + // } + } + // if (jsonObject.has("extra")) { + // object = GsonHelper.getAsJsonArray(jsonObject, "extra"); + // if (object.size() <= 0) throw new JsonParseException("Unexpected empty array of components"); + // for (int i = 0; i < object.size(); ++i) { + // var5_17.append(this.deserialize(object.get(i), type, jsonDeserializationContext)); + // } + // } + // var5_17.setStyle((Style)jsonDeserializationContext.deserialize(jsonElement, Style.class)); + // return var5_17; + // } + if json.get("extra").is_some() { + let extra = match json.get("extra").unwrap().as_array() { + Some(r) => r, + None => return Err(de::Error::custom("Extra isn't an array")), + }; + if extra.is_empty() { + return Err(de::Error::custom("Unexpected empty array of components")); + } + for extra_component in extra { + let sibling = + Component::deserialize(extra_component).map_err(de::Error::custom)?; + component.append(sibling); + } + } + + let style = Style::deserialize(&json); + component.get_base_mut().style = style; + + return Ok(component); + } + // ok so it's not an object, if it's an array deserialize every item + else if !json.is_array() { + return Err(de::Error::custom( + format!("Don't know how to turn {} into a Component", json).as_str(), + )); + } + let json_array = json.as_array().unwrap(); + // the first item in the array is the one that we're gonna return, the others are siblings + let mut component = Component::deserialize(&json_array[0]).map_err(de::Error::custom)?; + for i in 1..json_array.len() { + component.append( + Component::deserialize(json_array.get(i).unwrap()).map_err(de::Error::custom)?, + ); + } + Ok(component) + } +} diff --git a/azalea-chat/src/events.rs b/azalea-chat/src/events.rs new file mode 100644 index 00000000..a547169e --- /dev/null +++ b/azalea-chat/src/events.rs @@ -0,0 +1,26 @@ +enum ClickAction { + OPEN_URL = Action::new("open_url", true), + OPEN_FILE = Action::new("open_file", false), + RUN_COMMAND = Action::new("run_command", true), + SUGGEST_COMMAND = Action::new("suggest_command", true), + CHANGE_PAGE = Action::new("change_page", true), + COPY_TO_CLIPBOARD = Action::new("copy_to_clipboard", true), +} + +struct ClickAction { + pub name: String, + pub allow_from_server: bool, +} + +impl ClickAction { + fn new(name: &str, allow_from_server: bool) -> Self { + Self { + name: name.to_string(), + allow_from_server, + } + } +} + +struct ClickEvent { + action: ClickAction, +} diff --git a/azalea-chat/src/lib.rs b/azalea-chat/src/lib.rs new file mode 100644 index 00000000..b7035e13 --- /dev/null +++ b/azalea-chat/src/lib.rs @@ -0,0 +1,11 @@ +//! Things for working with Minecraft chat messages. +//! This was inspired by Minecraft and prismarine-chat. + +#[macro_use] +extern crate lazy_static; + +pub mod base_component; +pub mod component; +pub mod style; +pub mod text_component; +pub mod translatable_component; diff --git a/azalea-chat/src/style.rs b/azalea-chat/src/style.rs new file mode 100644 index 00000000..4e3b24de --- /dev/null +++ b/azalea-chat/src/style.rs @@ -0,0 +1,478 @@ +use std::{collections::HashMap, fmt}; + +use serde_json::Value; + +#[derive(Clone, PartialEq, Debug)] +pub struct TextColor { + pub value: u32, + pub name: Option<String>, +} + +impl TextColor { + pub fn parse(value: String) -> Result<TextColor, String> { + if value.starts_with('#') { + let n = value.chars().skip(1).collect::<String>(); + let n = u32::from_str_radix(&n, 16).unwrap(); + return Ok(TextColor::from_rgb(n)); + } + let color_option = NAMED_COLORS.get(&value.to_ascii_uppercase()); + if let Some(color) = color_option { + return Ok(color.clone()); + } + Err(format!("Invalid color {}", value)) + } + + fn from_rgb(value: u32) -> TextColor { + TextColor { value, name: None } + } +} + +lazy_static! { + static ref LEGACY_FORMAT_TO_COLOR: HashMap<&'static ChatFormatting<'static>, TextColor> = { + let mut legacy_format_to_color = HashMap::new(); + for formatter in &ChatFormatting::FORMATTERS { + if !formatter.is_format && *formatter != ChatFormatting::RESET { + legacy_format_to_color.insert( + formatter, + TextColor { + value: formatter.color.unwrap(), + name: Some(formatter.name.to_string()), + }, + ); + } + } + legacy_format_to_color + }; + static ref NAMED_COLORS: HashMap<String, TextColor> = { + 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 + }; +} + +#[derive(Clone, PartialEq, Eq, Hash, Debug)] +pub struct ChatFormatting<'a> { + pub name: &'a str, + pub code: char, + pub is_format: bool, + pub id: i32, + pub color: Option<u32>, +} + +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"; + 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 + ) + } +} + +impl<'a> ChatFormatting<'a> { + pub const BLACK: ChatFormatting<'a> = ChatFormatting::new("BLACK", '0', false, 0, Some(0)); + pub const DARK_BLUE: ChatFormatting<'a> = + ChatFormatting::new("DARK_BLUE", '1', false, 1, Some(170)); + pub const DARK_GREEN: ChatFormatting<'a> = + ChatFormatting::new("DARK_GREEN", '2', false, 2, Some(43520)); + pub const DARK_AQUA: ChatFormatting<'a> = + ChatFormatting::new("DARK_AQUA", '3', false, 3, Some(43690)); + pub const DARK_RED: ChatFormatting<'a> = + ChatFormatting::new("DARK_RED", '4', false, 4, Some(1114112)); + pub const DARK_PURPLE: ChatFormatting<'a> = + ChatFormatting::new("DARK_PURPLE", '5', false, 5, Some(11141290)); + pub const GOLD: ChatFormatting<'a> = ChatFormatting::new("GOLD", '6', false, 6, Some(16755200)); + pub const GRAY: ChatFormatting<'a> = ChatFormatting::new("GRAY", '7', false, 7, Some(11184810)); + pub const DARK_GRAY: ChatFormatting<'a> = + ChatFormatting::new("DARK_GRAY", '8', false, 8, Some(5592405)); + pub const BLUE: ChatFormatting<'a> = ChatFormatting::new("BLUE", '9', false, 9, Some(5592575)); + pub const GREEN: ChatFormatting<'a> = + ChatFormatting::new("GREEN", 'a', false, 10, Some(5635925)); + pub const AQUA: ChatFormatting<'a> = ChatFormatting::new("AQUA", 'b', false, 11, Some(5636095)); + pub const RED: ChatFormatting<'a> = ChatFormatting::new("RED", 'c', false, 12, Some(16733525)); + pub const LIGHT_PURPLE: ChatFormatting<'a> = + ChatFormatting::new("LIGHT_PURPLE", 'd', false, 13, Some(16733695)); + pub const YELLOW: ChatFormatting<'a> = + ChatFormatting::new("YELLOW", 'e', false, 14, Some(16777045)); + pub const WHITE: ChatFormatting<'a> = + ChatFormatting::new("WHITE", 'f', false, 15, Some(16777215)); + pub const OBFUSCATED: ChatFormatting<'a> = + ChatFormatting::new("OBFUSCATED", 'k', true, -1, None); + pub const STRIKETHROUGH: ChatFormatting<'a> = + ChatFormatting::new("STRIKETHROUGH", 'm', true, -1, None); + pub const BOLD: ChatFormatting<'a> = ChatFormatting::new("BOLD", 'l', true, -1, None); + pub const UNDERLINE: ChatFormatting<'a> = ChatFormatting::new("UNDERLINE", 'n', true, -1, None); + pub const ITALIC: ChatFormatting<'a> = ChatFormatting::new("ITALIC", 'o', true, -1, None); + pub const RESET: ChatFormatting<'a> = ChatFormatting::new("RESET", 'r', true, -1, None); + + pub const FORMATTERS: [ChatFormatting<'a>; 22] = [ + ChatFormatting::BLACK, + ChatFormatting::DARK_BLUE, + ChatFormatting::DARK_GREEN, + ChatFormatting::DARK_AQUA, + ChatFormatting::DARK_RED, + ChatFormatting::DARK_PURPLE, + ChatFormatting::GOLD, + ChatFormatting::GRAY, + ChatFormatting::DARK_GRAY, + ChatFormatting::BLUE, + ChatFormatting::GREEN, + ChatFormatting::AQUA, + ChatFormatting::RED, + ChatFormatting::LIGHT_PURPLE, + ChatFormatting::YELLOW, + ChatFormatting::WHITE, + ChatFormatting::OBFUSCATED, + ChatFormatting::STRIKETHROUGH, + ChatFormatting::BOLD, + ChatFormatting::UNDERLINE, + ChatFormatting::ITALIC, + ChatFormatting::RESET, + ]; + + const fn new( + name: &str, + code: char, + is_format: bool, + id: i32, + color: Option<u32>, + ) -> ChatFormatting { + ChatFormatting { + name, + code, + is_format, + id, + color, + } + } + + pub fn from_code(code: char) -> Result<&'static ChatFormatting<'static>, String> { + for formatter in &ChatFormatting::FORMATTERS { + if formatter.code == code { + return Ok(formatter); + } + } + Err(format!("Invalid formatting code {}", code)) + } +} + +impl TextColor { + fn new(value: u32, name: Option<String>) -> Self { + Self { value, name } + } + + pub fn format(&self) -> String { + format!("#{:06X}", self.value) + } +} + +impl fmt::Display for TextColor { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + if let Some(name) = &self.name { + write!(f, "{}", name.clone()) + } else { + write!(f, "{}", self.format()) + } + } +} + +// from ChatFormatting to TextColor +impl TryFrom<ChatFormatting<'_>> for TextColor { + type Error = String; + + fn try_from(formatter: ChatFormatting<'_>) -> Result<Self, Self::Error> { + 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_string()))) + } +} + +#[derive(Clone, Debug)] +pub struct Style { + // these are options instead of just bools because None is different than false in this case + pub color: Option<TextColor>, + pub bold: Option<bool>, + pub italic: Option<bool>, + pub underlined: Option<bool>, + pub strikethrough: Option<bool>, + pub obfuscated: Option<bool>, + /// Whether it should reset the formatting before applying these styles + pub reset: bool, +} + +impl Style { + pub fn default() -> Self { + Self::empty() + } + + pub fn empty() -> Self { + Self { + color: None, + bold: None, + italic: None, + underlined: None, + strikethrough: None, + obfuscated: None, + reset: false, + } + } + + pub fn deserialize(json: &Value) -> Style { + return if json.is_object() { + let json_object = json.as_object().unwrap(); + let bold = json_object.get("bold").and_then(|v| v.as_bool()); + let italic = json_object.get("italic").and_then(|v| v.as_bool()); + let underlined = json_object.get("underlined").and_then(|v| v.as_bool()); + let strikethrough = json_object.get("strikethrough").and_then(|v| v.as_bool()); + let obfuscated = json_object.get("obfuscated").and_then(|v| v.as_bool()); + let color: Option<TextColor> = json_object + .get("color") + .and_then(|v| v.as_str()) + .and_then(|v| TextColor::parse(v.to_string()).ok()); + Style { + color, + bold, + italic, + underlined, + strikethrough, + obfuscated, + ..Style::default() + } + } else { + Style::default() + }; + } + + /// 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, default_style: &Style) -> String { + let should_reset = after.reset || + // if it used to be bold and now it's not, reset + (self.bold.unwrap_or(false) && !after.bold.unwrap_or(true)) || + // if it used to be italic and now it's not, reset + (self.italic.unwrap_or(false) && !after.italic.unwrap_or(true)) || + // if it used to be underlined and now it's not, reset + (self.underlined.unwrap_or(false) && !after.underlined.unwrap_or(true)) || + // if it used to be strikethrough and now it's not, reset + (self.strikethrough.unwrap_or(false) && !after.strikethrough.unwrap_or(true)) || + // if it used to be obfuscated and now it's not, reset + (self.obfuscated.unwrap_or(false) && !after.obfuscated.unwrap_or(true)); + + let mut ansi_codes = String::new(); + + let empty_style = Style::empty(); + + let (before, after) = if should_reset { + ansi_codes.push_str(Ansi::RESET); + let mut updated_after = if after.reset { + default_style.clone() + } else { + self.clone() + }; + updated_after.apply(after); + (&empty_style, updated_after) + } else { + (self, after.clone()) + }; + + // if bold used to be false/default and now it's true, set bold + if !before.bold.unwrap_or(false) && after.bold.unwrap_or(false) { + ansi_codes.push_str(Ansi::BOLD); + } + // if italic used to be false/default and now it's true, set italic + if !before.italic.unwrap_or(false) && after.italic.unwrap_or(false) { + ansi_codes.push_str(Ansi::ITALIC); + } + // if underlined used to be false/default and now it's true, set underlined + if !before.underlined.unwrap_or(false) && after.underlined.unwrap_or(false) { + ansi_codes.push_str(Ansi::UNDERLINED); + } + // if strikethrough used to be false/default and now it's true, set strikethrough + if !before.strikethrough.unwrap_or(false) && after.strikethrough.unwrap_or(false) { + ansi_codes.push_str(Ansi::STRIKETHROUGH); + } + // if obfuscated used to be false/default and now it's true, set obfuscated + if !before.obfuscated.unwrap_or(false) && after.obfuscated.unwrap_or(false) { + 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 before.color.is_some() && after.color.is_some() { + before.color.clone().unwrap().value != after.color.as_ref().unwrap().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 + } + + /// Apply another style to this one + pub fn apply(&mut self, style: &Style) { + if let Some(color) = &style.color { + self.color = Some(color.clone()); + } + if let Some(bold) = &style.bold { + self.bold = Some(*bold); + } + if let Some(italic) = &style.italic { + self.italic = Some(*italic); + } + if let Some(underlined) = &style.underlined { + self.underlined = Some(*underlined); + } + if let Some(strikethrough) = &style.strikethrough { + self.strikethrough = Some(*strikethrough); + } + if let Some(obfuscated) = &style.obfuscated { + self.obfuscated = Some(*obfuscated); + } + } + + /// 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.reset = true, + ChatFormatting { + name: _, + code: _, + is_format: _, + id: _, + color, + } => { + // if it's a color, set it + if let Some(color) = color { + self.color = Some(TextColor::from_rgb(color)); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use crate::component::DEFAULT_STYLE; + + use super::*; + + #[test] + fn text_color_named_colors() { + assert_eq!(TextColor::parse("red".to_string()).unwrap().value, 16733525); + } + #[test] + fn text_color_hex_colors() { + assert_eq!( + TextColor::parse("#a1b2c3".to_string()).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), + ..Style::default() + }; + let ansi_difference = style_a.compare_ansi(&style_b, &Style::default()); + 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 { + italic: Some(true), + ..Style::default() + }; + let ansi_difference = style_a.compare_ansi(&style_b, &Style::default()); + assert_eq!(ansi_difference, Ansi::ITALIC) + } + + #[test] + fn ansi_difference_explicit_reset() { + let style_a = Style { + bold: Some(true), + ..Style::empty() + }; + let style_b = Style { + italic: Some(true), + reset: true, + ..Style::empty() + }; + let ansi_difference = style_a.compare_ansi(&style_b, &DEFAULT_STYLE); + assert_eq!( + ansi_difference, + format!( + "{reset}{italic}{white}", + reset = Ansi::RESET, + white = Ansi::rgb(ChatFormatting::WHITE.color.unwrap()), + italic = 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))); + } +} diff --git a/azalea-chat/src/text_component.rs b/azalea-chat/src/text_component.rs new file mode 100644 index 00000000..6c43f8b7 --- /dev/null +++ b/azalea-chat/src/text_component.rs @@ -0,0 +1,122 @@ +use std::fmt; + +use crate::{base_component::BaseComponent, component::Component, style::ChatFormatting}; + +#[derive(Clone, Debug)] +pub struct TextComponent { + pub base: BaseComponent, + pub text: String, +} + +const LEGACY_FORMATTING_CODE_SYMBOL: char = '§'; + +/// Convert a legacy color code string into a Component +/// Technically in Minecraft this is done when displaying the text, but AFAIK it's the same as just doing it in TextComponent +pub fn legacy_color_code_to_text_component(legacy_color_code: &str) -> TextComponent { + let mut components: Vec<TextComponent> = Vec::with_capacity(1); + // iterate over legacy_color_code, if it starts with LEGACY_COLOR_CODE_SYMBOL then read the next character and get the style from that + // otherwise, add the character to the text + + // we don't use a normal for loop since we need to be able to skip after reading the formatter code symbol + let mut i = 0; + while i < legacy_color_code.chars().count() { + if legacy_color_code.chars().nth(i).unwrap() == LEGACY_FORMATTING_CODE_SYMBOL { + let formatting_code = legacy_color_code.chars().nth(i + 1).unwrap(); + if let Ok(formatter) = ChatFormatting::from_code(formatting_code) { + if components.is_empty() || !components.last().unwrap().text.is_empty() { + components.push(TextComponent::new("".to_string())); + } + + let style = &mut components.last_mut().unwrap().base.style; + // if the formatter is a reset, then we need to reset the style to the default + style.apply_formatting(formatter); + } + i += 1; + } else { + if components.is_empty() { + components.push(TextComponent::new("".to_string())); + } + components + .last_mut() + .unwrap() + .text + .push(legacy_color_code.chars().nth(i).unwrap()); + }; + i += 1; + } + + // create the final component by using the first one as the base, and then adding the rest as siblings + let mut final_component = components.remove(0); + for component in components { + final_component.base.siblings.push(component.get()); + } + + final_component +} + +impl<'a> TextComponent { + pub fn new(text: String) -> Self { + // if it contains a LEGACY_FORMATTING_CODE_SYMBOL, format it + if text.contains(LEGACY_FORMATTING_CODE_SYMBOL) { + legacy_color_code_to_text_component(&text) + } else { + Self { + base: BaseComponent::new(), + text, + } + } + } + + fn get(self) -> Component { + Component::Text(self) + } +} + +impl fmt::Display for TextComponent { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.text.clone()) + } +} + +#[cfg(test)] +mod tests { + use crate::style::Ansi; + + use super::*; + + #[test] + fn test_hypixel_motd() { + let component = + TextComponent::new("§aHypixel Network §c[1.8-1.18]\n§b§lHAPPY HOLIDAYS".to_string()) + .get(); + assert_eq!( + component.to_ansi(None), + format!( + "{GREEN}Hypixel Network {RED}[1.8-1.18]\n{BOLD}{AQUA}HAPPY HOLIDAYS{RESET}", + GREEN = Ansi::rgb(ChatFormatting::GREEN.color.unwrap()), + RED = Ansi::rgb(ChatFormatting::RED.color.unwrap()), + AQUA = Ansi::rgb(ChatFormatting::AQUA.color.unwrap()), + BOLD = Ansi::BOLD, + RESET = Ansi::RESET + ) + ); + } + + #[test] + fn test_legacy_color_code_to_component() { + let component = TextComponent::new("§lHello §r§1w§2o§3r§4l§5d".to_string()).get(); + assert_eq!( + component.to_ansi(None), + format!( + "{BOLD}Hello {RESET}{DARK_BLUE}w{DARK_GREEN}o{DARK_AQUA}r{DARK_RED}l{DARK_PURPLE}d{RESET}", + BOLD = Ansi::BOLD, + RESET = Ansi::RESET, + DARK_BLUE = Ansi::rgb(ChatFormatting::DARK_BLUE.color.unwrap()), + DARK_GREEN = Ansi::rgb(ChatFormatting::DARK_GREEN.color.unwrap()), + DARK_AQUA = Ansi::rgb(ChatFormatting::DARK_AQUA.color.unwrap()), + DARK_RED = Ansi::rgb(ChatFormatting::DARK_RED.color.unwrap()), + DARK_PURPLE = Ansi::rgb(ChatFormatting::DARK_PURPLE.color.unwrap()) + ) + ); + } +} diff --git a/azalea-chat/src/translatable_component.rs b/azalea-chat/src/translatable_component.rs new file mode 100644 index 00000000..0709f7bf --- /dev/null +++ b/azalea-chat/src/translatable_component.rs @@ -0,0 +1,24 @@ +use crate::{base_component::BaseComponent, component::Component}; + +#[derive(Clone, Debug)] +pub enum StringOrComponent { + String(String), + Component(Component), +} + +#[derive(Clone, Debug)] +pub struct TranslatableComponent { + pub base: BaseComponent, + pub key: String, + pub args: Vec<StringOrComponent>, +} + +impl TranslatableComponent { + pub fn new(key: String, args: Vec<StringOrComponent>) -> Self { + Self { + base: BaseComponent::new(), + key, + args, + } + } +} |
