use crate::nbt::{NbtCompound, NbtList, NbtTag};
use crate::{Decode, Encode, EncodedSize, Error, Result};
#[derive(Debug, Clone, PartialEq)]
pub struct TextComponent {
pub content: TextContent,
pub style: TextStyle,
pub extra: Vec<TextComponent>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum TextContent {
Text(String),
Translate {
key: String,
with: Vec<TextComponent>,
},
Keybind(String),
Score {
name: String,
objective: String,
},
Selector(String),
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct TextStyle {
pub color: Option<TextColor>,
pub bold: Option<bool>,
pub italic: Option<bool>,
pub underlined: Option<bool>,
pub strikethrough: Option<bool>,
pub obfuscated: Option<bool>,
pub insertion: Option<String>,
pub click_event: Option<ClickEvent>,
pub hover_event: Option<HoverEvent>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum TextColor {
Named(NamedColor),
Hex(u32),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum NamedColor {
Black,
DarkBlue,
DarkGreen,
DarkAqua,
DarkRed,
DarkPurple,
Gold,
Gray,
DarkGray,
Blue,
Green,
Aqua,
Red,
LightPurple,
Yellow,
White,
}
impl NamedColor {
fn as_str(&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",
}
}
fn from_str(s: &str) -> Option<Self> {
match s {
"black" => Some(Self::Black),
"dark_blue" => Some(Self::DarkBlue),
"dark_green" => Some(Self::DarkGreen),
"dark_aqua" => Some(Self::DarkAqua),
"dark_red" => Some(Self::DarkRed),
"dark_purple" => Some(Self::DarkPurple),
"gold" => Some(Self::Gold),
"gray" => Some(Self::Gray),
"dark_gray" => Some(Self::DarkGray),
"blue" => Some(Self::Blue),
"green" => Some(Self::Green),
"aqua" => Some(Self::Aqua),
"red" => Some(Self::Red),
"light_purple" => Some(Self::LightPurple),
"yellow" => Some(Self::Yellow),
"white" => Some(Self::White),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum ClickEvent {
OpenUrl(String),
RunCommand(String),
SuggestCommand(String),
CopyToClipboard(String),
}
#[derive(Debug, Clone, PartialEq)]
pub enum HoverEvent {
ShowText(Box<TextComponent>),
ShowItem {
id: String,
count: i32,
tag: Option<String>,
},
ShowEntity {
id: String,
type_id: String,
name: Option<Box<TextComponent>>,
},
}
impl TextComponent {
pub fn text(text: impl Into<String>) -> Self {
Self {
content: TextContent::Text(text.into()),
style: TextStyle::default(),
extra: Vec::new(),
}
}
pub fn translate(key: impl Into<String>, with: Vec<TextComponent>) -> Self {
Self {
content: TextContent::Translate {
key: key.into(),
with,
},
style: TextStyle::default(),
extra: Vec::new(),
}
}
pub fn color(mut self, color: TextColor) -> Self {
self.style.color = Some(color);
self
}
pub fn bold(mut self, bold: bool) -> Self {
self.style.bold = Some(bold);
self
}
pub fn italic(mut self, italic: bool) -> Self {
self.style.italic = Some(italic);
self
}
pub fn underlined(mut self, underlined: bool) -> Self {
self.style.underlined = Some(underlined);
self
}
pub fn strikethrough(mut self, strikethrough: bool) -> Self {
self.style.strikethrough = Some(strikethrough);
self
}
pub fn obfuscated(mut self, obfuscated: bool) -> Self {
self.style.obfuscated = Some(obfuscated);
self
}
pub fn click_event(mut self, event: ClickEvent) -> Self {
self.style.click_event = Some(event);
self
}
pub fn hover_event(mut self, event: HoverEvent) -> Self {
self.style.hover_event = Some(event);
self
}
pub fn append(mut self, child: TextComponent) -> Self {
self.extra.push(child);
self
}
pub fn to_nbt(&self) -> NbtCompound {
component_to_nbt(self)
}
}
fn component_to_nbt(component: &TextComponent) -> NbtCompound {
let mut nbt = NbtCompound::new();
match &component.content {
TextContent::Text(text) => {
nbt.insert("text", NbtTag::String(text.clone()));
}
TextContent::Translate { key, with } => {
nbt.insert("translate", NbtTag::String(key.clone()));
if !with.is_empty() {
let list_tags: Vec<NbtTag> = with
.iter()
.map(|c| NbtTag::Compound(component_to_nbt(c)))
.collect();
let list = NbtList::from_tags(list_tags).unwrap();
nbt.insert("with", NbtTag::List(list));
}
}
TextContent::Keybind(key) => {
nbt.insert("keybind", NbtTag::String(key.clone()));
}
TextContent::Score { name, objective } => {
let mut score = NbtCompound::new();
score.insert("name", NbtTag::String(name.clone()));
score.insert("objective", NbtTag::String(objective.clone()));
nbt.insert("score", NbtTag::Compound(score));
}
TextContent::Selector(selector) => {
nbt.insert("selector", NbtTag::String(selector.clone()));
}
}
let style = &component.style;
if let Some(color) = &style.color {
let color_str = match color {
TextColor::Named(named) => named.as_str().to_string(),
TextColor::Hex(rgb) => format!("#{rgb:06x}"),
};
nbt.insert("color", NbtTag::String(color_str));
}
if let Some(bold) = style.bold {
nbt.insert("bold", NbtTag::Byte(bold as i8));
}
if let Some(italic) = style.italic {
nbt.insert("italic", NbtTag::Byte(italic as i8));
}
if let Some(underlined) = style.underlined {
nbt.insert("underlined", NbtTag::Byte(underlined as i8));
}
if let Some(strikethrough) = style.strikethrough {
nbt.insert("strikethrough", NbtTag::Byte(strikethrough as i8));
}
if let Some(obfuscated) = style.obfuscated {
nbt.insert("obfuscated", NbtTag::Byte(obfuscated as i8));
}
if let Some(insertion) = &style.insertion {
nbt.insert("insertion", NbtTag::String(insertion.clone()));
}
if let Some(click) = &style.click_event {
let mut event = NbtCompound::new();
let (action, value) = match click {
ClickEvent::OpenUrl(url) => ("open_url", url.clone()),
ClickEvent::RunCommand(cmd) => ("run_command", cmd.clone()),
ClickEvent::SuggestCommand(cmd) => ("suggest_command", cmd.clone()),
ClickEvent::CopyToClipboard(text) => ("copy_to_clipboard", text.clone()),
};
event.insert("action", NbtTag::String(action.into()));
event.insert("value", NbtTag::String(value));
nbt.insert("clickEvent", NbtTag::Compound(event));
}
if let Some(hover) = &style.hover_event {
let mut event = NbtCompound::new();
match hover {
HoverEvent::ShowText(text) => {
event.insert("action", NbtTag::String("show_text".into()));
event.insert("contents", NbtTag::Compound(component_to_nbt(text)));
}
HoverEvent::ShowItem { id, count, tag } => {
event.insert("action", NbtTag::String("show_item".into()));
let mut contents = NbtCompound::new();
contents.insert("id", NbtTag::String(id.clone()));
contents.insert("count", NbtTag::Int(*count));
if let Some(tag) = tag {
contents.insert("tag", NbtTag::String(tag.clone()));
}
event.insert("contents", NbtTag::Compound(contents));
}
HoverEvent::ShowEntity { id, type_id, name } => {
event.insert("action", NbtTag::String("show_entity".into()));
let mut contents = NbtCompound::new();
contents.insert("type", NbtTag::String(type_id.clone()));
contents.insert("id", NbtTag::String(id.clone()));
if let Some(name) = name {
contents.insert("name", NbtTag::Compound(component_to_nbt(name)));
}
event.insert("contents", NbtTag::Compound(contents));
}
}
nbt.insert("hoverEvent", NbtTag::Compound(event));
}
if !component.extra.is_empty() {
let list_tags: Vec<NbtTag> = component
.extra
.iter()
.map(|c| NbtTag::Compound(component_to_nbt(c)))
.collect();
let list = NbtList::from_tags(list_tags).unwrap();
nbt.insert("extra", NbtTag::List(list));
}
nbt
}
fn component_from_nbt(nbt: &NbtCompound) -> Result<TextComponent> {
let content = if let Some(NbtTag::String(text)) = nbt.get("text") {
TextContent::Text(text.clone())
} else if let Some(NbtTag::String(key)) = nbt.get("translate") {
let with = if let Some(NbtTag::List(list)) = nbt.get("with") {
let mut components = Vec::new();
for tag in &list.elements {
if let NbtTag::Compound(c) = tag {
components.push(component_from_nbt(c)?);
}
}
components
} else {
Vec::new()
};
TextContent::Translate {
key: key.clone(),
with,
}
} else if let Some(NbtTag::String(key)) = nbt.get("keybind") {
TextContent::Keybind(key.clone())
} else if let Some(NbtTag::Compound(score)) = nbt.get("score") {
let name = match score.get("name") {
Some(NbtTag::String(s)) => s.clone(),
_ => return Err(Error::Nbt("score missing 'name'".into())),
};
let objective = match score.get("objective") {
Some(NbtTag::String(s)) => s.clone(),
_ => return Err(Error::Nbt("score missing 'objective'".into())),
};
TextContent::Score { name, objective }
} else if let Some(NbtTag::String(selector)) = nbt.get("selector") {
TextContent::Selector(selector.clone())
} else {
TextContent::Text(String::new())
};
let mut style = TextStyle::default();
if let Some(NbtTag::String(color_str)) = nbt.get("color") {
if let Some(named) = NamedColor::from_str(color_str) {
style.color = Some(TextColor::Named(named));
} else if let Some(hex) = color_str.strip_prefix('#')
&& let Ok(rgb) = u32::from_str_radix(hex, 16)
{
style.color = Some(TextColor::Hex(rgb));
}
}
fn read_bool(nbt: &NbtCompound, key: &str) -> Option<bool> {
match nbt.get(key) {
Some(NbtTag::Byte(v)) => Some(*v != 0),
_ => None,
}
}
style.bold = read_bool(nbt, "bold");
style.italic = read_bool(nbt, "italic");
style.underlined = read_bool(nbt, "underlined");
style.strikethrough = read_bool(nbt, "strikethrough");
style.obfuscated = read_bool(nbt, "obfuscated");
if let Some(NbtTag::String(insertion)) = nbt.get("insertion") {
style.insertion = Some(insertion.clone());
}
if let Some(NbtTag::Compound(event)) = nbt.get("clickEvent")
&& let (Some(NbtTag::String(action)), Some(NbtTag::String(value))) =
(event.get("action"), event.get("value"))
{
style.click_event = match action.as_str() {
"open_url" => Some(ClickEvent::OpenUrl(value.clone())),
"run_command" => Some(ClickEvent::RunCommand(value.clone())),
"suggest_command" => Some(ClickEvent::SuggestCommand(value.clone())),
"copy_to_clipboard" => Some(ClickEvent::CopyToClipboard(value.clone())),
_ => None,
};
}
if let Some(NbtTag::Compound(event)) = nbt.get("hoverEvent")
&& let Some(NbtTag::String(action)) = event.get("action")
{
style.hover_event = match action.as_str() {
"show_text" => {
if let Some(NbtTag::Compound(contents)) = event.get("contents") {
Some(HoverEvent::ShowText(Box::new(component_from_nbt(
contents,
)?)))
} else {
None
}
}
"show_item" => {
if let Some(NbtTag::Compound(contents)) = event.get("contents") {
let id = match contents.get("id") {
Some(NbtTag::String(s)) => s.clone(),
_ => return Err(Error::Nbt("show_item missing 'id'".into())),
};
let count = match contents.get("count") {
Some(NbtTag::Int(n)) => *n,
_ => 1,
};
let tag = match contents.get("tag") {
Some(NbtTag::String(s)) => Some(s.clone()),
_ => None,
};
Some(HoverEvent::ShowItem { id, count, tag })
} else {
None
}
}
"show_entity" => {
if let Some(NbtTag::Compound(contents)) = event.get("contents") {
let id = match contents.get("id") {
Some(NbtTag::String(s)) => s.clone(),
_ => return Err(Error::Nbt("show_entity missing 'id'".into())),
};
let type_id = match contents.get("type") {
Some(NbtTag::String(s)) => s.clone(),
_ => return Err(Error::Nbt("show_entity missing 'type'".into())),
};
let name = if let Some(NbtTag::Compound(name_nbt)) = contents.get("name") {
Some(Box::new(component_from_nbt(name_nbt)?))
} else {
None
};
Some(HoverEvent::ShowEntity { id, type_id, name })
} else {
None
}
}
_ => None,
};
}
let extra = if let Some(NbtTag::List(list)) = nbt.get("extra") {
let mut children = Vec::new();
for tag in &list.elements {
if let NbtTag::Compound(c) = tag {
children.push(component_from_nbt(c)?);
}
}
children
} else {
Vec::new()
};
Ok(TextComponent {
content,
style,
extra,
})
}
impl Encode for TextComponent {
fn encode(&self, buf: &mut Vec<u8>) -> Result<()> {
let nbt = component_to_nbt(self);
nbt.encode(buf)
}
}
impl Decode for TextComponent {
fn decode(buf: &mut &[u8]) -> Result<Self> {
let nbt = NbtCompound::decode(buf)?;
component_from_nbt(&nbt)
}
}
impl EncodedSize for TextComponent {
fn encoded_size(&self) -> usize {
let nbt = component_to_nbt(self);
nbt.encoded_size()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn roundtrip(component: &TextComponent) {
let mut buf = Vec::with_capacity(component.encoded_size());
component.encode(&mut buf).unwrap();
assert_eq!(buf.len(), component.encoded_size());
let mut cursor = buf.as_slice();
let decoded = TextComponent::decode(&mut cursor).unwrap();
assert!(cursor.is_empty());
assert_eq!(decoded, *component);
}
#[test]
fn plain_text() {
roundtrip(&TextComponent::text("hello"));
}
#[test]
fn empty_text() {
roundtrip(&TextComponent::text(""));
}
#[test]
fn bold_red_text() {
let tc = TextComponent::text("warning")
.bold(true)
.color(TextColor::Named(NamedColor::Red));
roundtrip(&tc);
}
#[test]
fn all_formatting() {
let tc = TextComponent::text("styled")
.bold(true)
.italic(true)
.underlined(true)
.strikethrough(true)
.obfuscated(true);
roundtrip(&tc);
}
#[test]
fn hex_color() {
let tc = TextComponent::text("custom color").color(TextColor::Hex(0xFF5500));
roundtrip(&tc);
}
#[test]
fn all_named_colors() {
let colors = [
NamedColor::Black,
NamedColor::DarkBlue,
NamedColor::DarkGreen,
NamedColor::DarkAqua,
NamedColor::DarkRed,
NamedColor::DarkPurple,
NamedColor::Gold,
NamedColor::Gray,
NamedColor::DarkGray,
NamedColor::Blue,
NamedColor::Green,
NamedColor::Aqua,
NamedColor::Red,
NamedColor::LightPurple,
NamedColor::Yellow,
NamedColor::White,
];
for color in colors {
let tc = TextComponent::text("test").color(TextColor::Named(color));
roundtrip(&tc);
}
}
#[test]
fn insertion() {
let tc = TextComponent {
content: TextContent::Text("click me".into()),
style: TextStyle {
insertion: Some("/help".into()),
..Default::default()
},
extra: Vec::new(),
};
roundtrip(&tc);
}
#[test]
fn with_extra() {
let tc = TextComponent::text("hello ").append(TextComponent::text("world").bold(true));
roundtrip(&tc);
}
#[test]
fn nested_extra() {
let tc = TextComponent::text("a")
.append(TextComponent::text("b").append(TextComponent::text("c").italic(true)));
roundtrip(&tc);
}
#[test]
fn translate_no_args() {
let tc = TextComponent::translate("multiplayer.disconnect.kicked", vec![]);
roundtrip(&tc);
}
#[test]
fn translate_with_args() {
let tc = TextComponent::translate(
"chat.type.text",
vec![
TextComponent::text("Player1"),
TextComponent::text("Hello!"),
],
);
roundtrip(&tc);
}
#[test]
fn keybind() {
let tc = TextComponent {
content: TextContent::Keybind("key.jump".into()),
style: TextStyle::default(),
extra: Vec::new(),
};
roundtrip(&tc);
}
#[test]
fn score() {
let tc = TextComponent {
content: TextContent::Score {
name: "Player1".into(),
objective: "kills".into(),
},
style: TextStyle::default(),
extra: Vec::new(),
};
roundtrip(&tc);
}
#[test]
fn selector() {
let tc = TextComponent {
content: TextContent::Selector("@a[distance=..10]".into()),
style: TextStyle::default(),
extra: Vec::new(),
};
roundtrip(&tc);
}
#[test]
fn click_open_url() {
let tc = TextComponent::text("click here")
.click_event(ClickEvent::OpenUrl("https://minecraft.net".into()));
roundtrip(&tc);
}
#[test]
fn click_run_command() {
let tc = TextComponent::text("run")
.click_event(ClickEvent::RunCommand("/gamemode creative".into()));
roundtrip(&tc);
}
#[test]
fn click_suggest_command() {
let tc =
TextComponent::text("suggest").click_event(ClickEvent::SuggestCommand("/tp ".into()));
roundtrip(&tc);
}
#[test]
fn click_copy() {
let tc =
TextComponent::text("copy").click_event(ClickEvent::CopyToClipboard("secret".into()));
roundtrip(&tc);
}
#[test]
fn hover_show_text() {
let tc = TextComponent::text("hover me").hover_event(HoverEvent::ShowText(Box::new(
TextComponent::text("tooltip").color(TextColor::Named(NamedColor::Yellow)),
)));
roundtrip(&tc);
}
#[test]
fn hover_show_item() {
let tc = TextComponent::text("item").hover_event(HoverEvent::ShowItem {
id: "minecraft:diamond_sword".into(),
count: 1,
tag: Some("{Damage:10}".into()),
});
roundtrip(&tc);
}
#[test]
fn hover_show_item_no_tag() {
let tc = TextComponent::text("item").hover_event(HoverEvent::ShowItem {
id: "minecraft:stone".into(),
count: 64,
tag: None,
});
roundtrip(&tc);
}
#[test]
fn hover_show_entity() {
let tc = TextComponent::text("entity").hover_event(HoverEvent::ShowEntity {
id: "550e8400-e29b-41d4-a716-446655440000".into(),
type_id: "minecraft:creeper".into(),
name: Some(Box::new(
TextComponent::text("Bob").color(TextColor::Named(NamedColor::Green)),
)),
});
roundtrip(&tc);
}
#[test]
fn hover_show_entity_no_name() {
let tc = TextComponent::text("entity").hover_event(HoverEvent::ShowEntity {
id: "550e8400-e29b-41d4-a716-446655440000".into(),
type_id: "minecraft:zombie".into(),
name: None,
});
roundtrip(&tc);
}
#[test]
fn complex_component() {
let tc = TextComponent::text("[")
.color(TextColor::Named(NamedColor::Gray))
.append(
TextComponent::text("Server")
.color(TextColor::Named(NamedColor::Gold))
.bold(true),
)
.append(TextComponent::text("] ").color(TextColor::Named(NamedColor::Gray)))
.append(
TextComponent::text("Welcome!")
.color(TextColor::Named(NamedColor::White))
.click_event(ClickEvent::RunCommand("/help".into()))
.hover_event(HoverEvent::ShowText(Box::new(TextComponent::text(
"Click for help",
)))),
);
roundtrip(&tc);
}
}