diff --git a/.gitignore b/.gitignore index d73a3be35..8a952a36f 100644 --- a/.gitignore +++ b/.gitignore @@ -153,3 +153,4 @@ spigot.yml version_history.json world_nether/ world_the_end/ +patchbukkit/ diff --git a/pumpkin-util/src/text/legacy.rs b/pumpkin-util/src/text/legacy.rs new file mode 100644 index 000000000..a2a6002ae --- /dev/null +++ b/pumpkin-util/src/text/legacy.rs @@ -0,0 +1,291 @@ +use std::fmt::Write; + +use crate::text::color::Color; +use crate::text::{TextComponent, TextContent}; +use crate::translation::{Locale, get_translation_text}; + +impl TextComponent { + /// Parses a legacy Minecraft formatted string (using section signs '§' by default) into a text component. + /// + /// Legacy formatting uses formatting codes after a color symbol: + /// - Colors: 0-9, a-f + /// - Styles: l (bold), o (italic), n (underline), m (strikethrough), k (obfuscated) + /// - Reset: r + /// - RGB hex colors: §x§R§R§G§G§B§B + /// + /// # Arguments + /// - `input` – The legacy formatted string. + /// + /// # Returns + /// A `TextComponent` with the parsed formatting applied. + #[must_use] + pub fn from_legacy_string(input: &str) -> Self { + Self::from_legacy_string_with_code(input, '§') + } + + /// Parses a legacy formatted string using a custom color code symbol (e.g. '&' or '§'). + /// + /// # Arguments + /// - `input` – The legacy formatted string. + /// - `code_symbol` – The delimiter character (e.g. `'§'` or `'&'`). + /// + /// # Returns + /// A `TextComponent` with the parsed formatting applied. + #[must_use] + #[expect(clippy::too_many_lines)] + pub fn from_legacy_string_with_code(input: &str, code_symbol: char) -> Self { + let mut root = Self::text(""); + let mut parts = input.split(code_symbol); + + if let Some(first) = parts.next() + && !first.is_empty() + { + root = root.add_child(Self::text(first.to_string())); + } + + let mut current_color: Option = None; + let mut bold = false; + let mut italic = false; + let mut underlined = false; + let mut strikethrough = false; + let mut obfuscated = false; + + while let Some(part) = parts.next() { + if part.is_empty() { + continue; + } + + let code = part.chars().next().unwrap_or(' ').to_ascii_lowercase(); + let remainder = &part[code.len_utf8()..]; + + match code { + 'x' => { + let mut hex = [0u8; 6]; + let mut valid_hex = true; + let mut trailing_remainder = String::new(); + for hex_byte in &mut hex { + if let Some(next_part) = parts.next() { + if let Some(c) = next_part.chars().next() { + *hex_byte = c as u8; + trailing_remainder.push_str(&next_part[c.len_utf8()..]); + } else { + valid_hex = false; + break; + } + } else { + valid_hex = false; + break; + } + } + if valid_hex && let Ok(hex_str) = std::str::from_utf8(&hex) { + current_color = Color::from_hex_str(hex_str); + } + if !remainder.is_empty() || !trailing_remainder.is_empty() { + let text = format!("{remainder}{trailing_remainder}"); + if !text.is_empty() { + let mut child = Self::text(text); + if let Some(c) = current_color { + child = child.color(c); + } + if bold { + child = child.bold(); + } + if italic { + child = child.italic(); + } + if underlined { + child = child.underlined(); + } + if strikethrough { + child = child.strikethrough(); + } + if obfuscated { + child = child.obfuscated(); + } + root = root.add_child(child); + } + } + continue; + } + '0'..='9' | 'a'..='f' => { + current_color = Color::from_legacy_code(code); + bold = false; + italic = false; + underlined = false; + strikethrough = false; + obfuscated = false; + } + 'l' => bold = true, + 'o' => italic = true, + 'n' => underlined = true, + 'm' => strikethrough = true, + 'k' => obfuscated = true, + 'r' => { + current_color = None; + bold = false; + italic = false; + underlined = false; + strikethrough = false; + obfuscated = false; + } + _ => {} + } + + if !remainder.is_empty() { + let mut child = Self::text(remainder.to_string()); + if let Some(c) = current_color { + child = child.color(c); + } + if bold { + child = child.bold(); + } + if italic { + child = child.italic(); + } + if underlined { + child = child.underlined(); + } + if strikethrough { + child = child.strikethrough(); + } + if obfuscated { + child = child.obfuscated(); + } + root = root.add_child(child); + } + } + + root + } + + /// Serializes this component into a Java legacy formatted string using `§`. + /// + /// # Arguments + /// - `locale` – The locale to use for resolving translation components. + /// + /// # Returns + /// A legacy Minecraft formatted string with `§` color and style codes. + #[must_use] + pub fn to_legacy_string(&self, locale: Locale) -> String { + self.to_legacy_string_with_code('§', locale) + } + + /// Serializes this component into a legacy formatted string using a custom color code symbol. + /// + /// # Arguments + /// - `code_symbol` – The formatting character symbol (e.g., `'§'` or `'&'`). + /// - `locale` – The locale to use for resolving translation components. + /// + /// # Returns + /// A legacy formatted string with color and style codes using `code_symbol`. + #[must_use] + pub fn to_legacy_string_with_code(&self, code_symbol: char, locale: Locale) -> String { + let mut text = String::new(); + + // 1. Color formatting + if let Some(color) = &self.0.style.color { + match color { + Color::Named(named) => { + let _ = write!(text, "{code_symbol}{}", named.to_legacy_char()); + } + Color::Rgb(rgb) => { + let hex = format!("{:02x}{:02x}{:02x}", rgb.red, rgb.green, rgb.blue); + let _ = write!(text, "{code_symbol}x"); + for ch in hex.chars() { + let _ = write!(text, "{code_symbol}{ch}"); + } + } + Color::Reset => { + let _ = write!(text, "{code_symbol}r"); + } + } + } + + // 2. Style formatting + if self.0.style.bold == Some(true) { + let _ = write!(text, "{code_symbol}l"); + } + if self.0.style.italic == Some(true) { + let _ = write!(text, "{code_symbol}o"); + } + if self.0.style.underlined == Some(true) { + let _ = write!(text, "{code_symbol}n"); + } + if self.0.style.strikethrough == Some(true) { + let _ = write!(text, "{code_symbol}m"); + } + if self.0.style.obfuscated == Some(true) { + let _ = write!(text, "{code_symbol}k"); + } + + // 3. Resolve Content + match &*self.0.content { + TextContent::Text { text: t } => text.push_str(t), + TextContent::Translate { + translate, + bedrock_translate: _, + with, + } => { + text.push_str(&get_translation_text( + format!("minecraft:{translate}"), + locale, + with.clone(), + )); + } + TextContent::EntityNames { selector, .. } => text.push_str(selector), + TextContent::Keybind { keybind } => text.push_str(keybind), + TextContent::Custom { key, with, .. } => { + text.push_str(&get_translation_text(key.clone(), locale, with.clone())); + } + } + + // 4. Recursively append extra components + for child in &self.0.extra { + text.push_str(&Self(child.clone()).to_legacy_string_with_code(code_symbol, locale)); + let _ = write!(text, "{code_symbol}r"); + } + + text + } +} + +#[cfg(test)] +mod test { + use crate::text::TextComponent; + use crate::text::color::{Color, NamedColor}; + use crate::translation::Locale; + + #[test] + fn from_legacy_string_hex() { + let comp = TextComponent::from_legacy_string("§x§f§c§0§0§0§0Test String ▽"); + assert_eq!(comp.0.extra.len(), 1); + if let crate::text::TextContent::Text { text } = &*comp.0.extra[0].content { + assert_eq!(text, "Test String ▽"); + } else { + panic!("Expected Text content"); + } + assert_eq!(comp.0.extra[0].style.color, Color::from_hex_str("fc0000")); + } + + #[test] + fn from_legacy_string_alt_code() { + let comp = TextComponent::from_legacy_string_with_code("&cRed &lBold", '&'); + assert_eq!(comp.0.extra.len(), 2); + assert_eq!( + comp.0.extra[0].style.color, + Some(Color::Named(NamedColor::Red)) + ); + assert_eq!(comp.0.extra[1].style.bold, Some(true)); + } + + #[test] + fn to_legacy_string() { + let comp = TextComponent::text("Hello ").add_child( + TextComponent::text("World") + .color_named(NamedColor::Red) + .bold(), + ); + let legacy = comp.to_legacy_string(Locale::EnUs); + assert_eq!(legacy, "Hello §c§lWorld§r"); + } +} diff --git a/pumpkin-util/src/text/mod.rs b/pumpkin-util/src/text/mod.rs index ebc01d94a..561c7e0b2 100644 --- a/pumpkin-util/src/text/mod.rs +++ b/pumpkin-util/src/text/mod.rs @@ -19,6 +19,7 @@ use style::Style; pub mod click; pub mod color; pub mod hover; +pub mod legacy; pub mod style; /// Represents a Minecraft chat component. @@ -525,118 +526,6 @@ impl TextComponent { }) } - /// Parses a legacy Minecraft formatted string (using section signs '§') into a text component. - /// - /// Legacy formatting uses the section sign (§) followed by a formatting code: - /// - Colors: 0-9, a-f - /// - Styles: l (bold), o (italic), n (underline), m (strikethrough), k (obfuscated) - /// - Reset: r - /// - RGB hex colors: §x§R§R§G§G§B§B - /// - /// # Arguments - /// - `input` – The legacy formatted string. - /// - /// # Returns - /// A `TextComponent` with the parsed formatting applied. - #[must_use] - pub fn from_legacy_string(input: &str) -> Self { - let mut root = Self::text(""); - let mut parts = input.split('§'); - - if let Some(first) = parts.next() - && !first.is_empty() - { - root = root.add_child(Self::text(first.to_string())); - } - - let mut current_color: Option = None; - let mut bold = false; - let mut italic = false; - let mut underlined = false; - let mut strikethrough = false; - let mut obfuscated = false; - - while let Some(part) = parts.next() { - if part.is_empty() { - continue; - } - - let code = part.chars().next().unwrap_or(' ').to_ascii_lowercase(); - let remainder = &part[1..]; - - match code { - 'x' => { - let mut hex = [0u8; 6]; - let mut valid_hex = true; - for hex_byte in &mut hex { - if let Some(next_part) = parts.next() { - if let Some(c) = next_part.chars().next() { - *hex_byte = c as u8; - } else { - valid_hex = false; - break; - } - } else { - valid_hex = false; - break; - } - } - if valid_hex && let Ok(hex_str) = std::str::from_utf8(&hex) { - current_color = Color::from_hex_str(hex_str); - } - continue; - } - '0'..='9' | 'a'..='f' => { - current_color = Color::from_legacy_code(code); - bold = false; - italic = false; - underlined = false; - strikethrough = false; - obfuscated = false; - } - 'l' => bold = true, - 'o' => italic = true, - 'n' => underlined = true, - 'm' => strikethrough = true, - 'k' => obfuscated = true, - 'r' => { - current_color = None; - bold = false; - italic = false; - underlined = false; - strikethrough = false; - obfuscated = false; - } - _ => {} - } - - if !remainder.is_empty() { - let mut child = Self::text(remainder.to_string()); - if let Some(c) = current_color { - child = child.color(c); - } - if bold { - child = child.bold(); - } - if italic { - child = child.italic(); - } - if underlined { - child = child.underlined(); - } - if strikethrough { - child = child.strikethrough(); - } - if obfuscated { - child = child.obfuscated(); - } - root = root.add_child(child); - } - } - - root - } - /// Appends a child component to this component. /// /// # Arguments