aboutsummaryrefslogtreecommitdiff
path: root/azalea-chat/src
diff options
context:
space:
mode:
authormat <github@matdoes.dev>2021-12-15 23:10:55 -0600
committermat <github@matdoes.dev>2021-12-15 23:10:55 -0600
commit9642558f8f8d983a7087f15d68be8cf07a85f0c2 (patch)
tree5f0a967f005cd5db510a13ab290c8ad6669b25aa /azalea-chat/src
parent72aefe871ca4983431b1a0b707b472e73ffea836 (diff)
downloadazalea-drasl-9642558f8f8d983a7087f15d68be8cf07a85f0c2.tar.xz
azalea
Diffstat (limited to 'azalea-chat/src')
-rw-r--r--azalea-chat/src/base_component.rs23
-rw-r--r--azalea-chat/src/component.rs262
-rw-r--r--azalea-chat/src/events.rs26
-rw-r--r--azalea-chat/src/lib.rs11
-rw-r--r--azalea-chat/src/style.rs478
-rw-r--r--azalea-chat/src/text_component.rs122
-rw-r--r--azalea-chat/src/translatable_component.rs24
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,
+ }
+ }
+}