// TODO: Documentation. // TODO: make fields of Text public? use std::borrow::Cow; use std::fmt; use std::io::{Read, Write}; use serde::de::Visitor; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use crate::protocol::{BoundedString, Decode, Encode}; use crate::Ident; /// Represents formatted text in Minecraft's JSON text format. /// /// Text is used in various places such as chat, window titles, /// disconnect messages, written books, signs, and more. /// /// For more information, see the relevant [Minecraft wiki article](https://minecraft.fandom.com/wiki/Raw_JSON_text_format). /// /// Note that the current `Deserialize` implementation on this type recognizes /// only a subset of the full JSON chat component format. /// /// ## Example /// With [`TextFormat`] in scope, you can write the following: /// ``` /// use valence::text::{Color, Text, TextFormat}; /// /// let txt = "The text is ".into_text() /// + "Red".color(Color::RED) /// + ", " /// + "Green".color(Color::GREEN) /// + ", and also " /// + "Blue".color(Color::BLUE) /// + "!\nAnd maybe even " /// + "Italic".italic() /// + "."; /// /// assert_eq!( /// txt.to_plain(), /// "The text is Red, Green, and also Blue!\nAnd maybe even Italic." /// ); /// ``` #[derive(Clone, PartialEq, Default, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Text { #[serde(flatten)] content: TextContent, #[serde(default, skip_serializing_if = "Option::is_none")] color: Option, #[serde(default, skip_serializing_if = "Option::is_none")] font: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] bold: Option, #[serde(default, skip_serializing_if = "Option::is_none")] italic: Option, #[serde(default, skip_serializing_if = "Option::is_none")] underlined: Option, #[serde(default, skip_serializing_if = "Option::is_none")] strikethrough: Option, #[serde(default, skip_serializing_if = "Option::is_none")] obfuscated: Option, #[serde(default, skip_serializing_if = "Option::is_none")] insertion: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] click_event: Option, #[serde(default, skip_serializing_if = "Option::is_none")] hover_event: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] extra: Vec, } /// Provides the methods necessary for working with [`Text`] objects. /// /// This trait exists to allow using `Into` types without having to first /// convert the type into [`Text`]. It is automatically implemented for all /// `Into` types, including [`Text`] itself. pub trait TextFormat: Into { fn into_text(self) -> Text { self.into() } fn color(self, color: Color) -> Text { let mut t = self.into(); t.color = Some(color); t } fn clear_color(self) -> Text { let mut t = self.into(); t.color = None; t } fn font(self, font: impl Into>) -> Text { let mut t = self.into(); t.font = Some(font.into()); t } fn clear_font(self) -> Text { let mut t = self.into(); t.font = None; t } fn bold(self) -> Text { let mut t = self.into(); t.bold = Some(true); t } fn not_bold(self) -> Text { let mut t = self.into(); t.bold = Some(false); t } fn clear_bold(self) -> Text { let mut t = self.into(); t.bold = None; t } fn italic(self) -> Text { let mut t = self.into(); t.italic = Some(true); t } fn not_italic(self) -> Text { let mut t = self.into(); t.italic = Some(false); t } fn clear_italic(self) -> Text { let mut t = self.into(); t.italic = None; t } fn underlined(self) -> Text { let mut t = self.into(); t.underlined = Some(true); t } fn not_underlined(self) -> Text { let mut t = self.into(); t.underlined = Some(false); t } fn clear_underlined(self) -> Text { let mut t = self.into(); t.underlined = None; t } fn strikethrough(self) -> Text { let mut t = self.into(); t.strikethrough = Some(true); t } fn not_strikethrough(self) -> Text { let mut t = self.into(); t.strikethrough = Some(false); t } fn clear_strikethrough(self) -> Text { let mut t = self.into(); t.strikethrough = None; t } fn obfuscated(self) -> Text { let mut t = self.into(); t.obfuscated = Some(true); t } fn not_obfuscated(self) -> Text { let mut t = self.into(); t.obfuscated = Some(false); t } fn clear_obfuscated(self) -> Text { let mut t = self.into(); t.obfuscated = None; t } fn insertion(self, insertion: impl Into>) -> Text { let mut t = self.into(); t.insertion = Some(insertion.into()); t } fn clear_insertion(self) -> Text { let mut t = self.into(); t.insertion = None; t } fn on_click_open_url(self, url: impl Into>) -> Text { let mut t = self.into(); t.click_event = Some(ClickEvent::OpenUrl(url.into())); t } fn on_click_run_command(self, command: impl Into>) -> Text { let mut t = self.into(); t.click_event = Some(ClickEvent::RunCommand(command.into())); t } fn on_click_suggest_command(self, command: impl Into>) -> Text { let mut t = self.into(); t.click_event = Some(ClickEvent::SuggestCommand(command.into())); t } fn on_click_change_page(self, page: impl Into) -> Text { let mut t = self.into(); t.click_event = Some(ClickEvent::ChangePage(page.into())); t } fn on_click_copy_to_clipboard(self, text: impl Into>) -> Text { let mut t = self.into(); t.click_event = Some(ClickEvent::CopyToClipboard(text.into())); t } fn clear_click_event(self) -> Text { let mut t = self.into(); t.click_event = None; t } fn on_hover_show_text(self, text: impl Into) -> Text { let mut t = self.into(); t.hover_event = Some(HoverEvent::ShowText(Box::new(text.into()))); t } fn clear_hover_event(self) -> Text { let mut t = self.into(); t.hover_event = None; t } fn add_child(self, text: impl Into) -> Text { let mut t = self.into(); t.extra.push(text.into()); t } } #[derive(Clone, PartialEq, Debug, Serialize, Deserialize)] #[serde(untagged)] enum TextContent { Text { text: Cow<'static, str>, }, Translate { translate: Cow<'static, str>, // TODO: 'with' field }, // TODO: score // TODO: entity names // TODO: keybind // TODO: nbt } #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] pub struct Color { pub r: u8, pub g: u8, pub b: u8, } #[derive(Clone, PartialEq, Debug, Serialize, Deserialize)] #[serde(tag = "action", content = "value", rename_all = "snake_case")] enum ClickEvent { OpenUrl(Cow<'static, str>), /// Only usable by internal servers for security reasons. OpenFile(Cow<'static, str>), RunCommand(Cow<'static, str>), SuggestCommand(Cow<'static, str>), ChangePage(i32), CopyToClipboard(Cow<'static, str>), } #[derive(Clone, PartialEq, Debug, Serialize, Deserialize)] #[serde(tag = "action", content = "contents", rename_all = "snake_case")] #[allow(clippy::enum_variant_names)] enum HoverEvent { ShowText(Box), ShowItem { id: Ident, count: Option, // TODO: tag }, ShowEntity { name: Box, #[serde(rename = "type")] typ: Ident, // TODO: id (hyphenated entity UUID as a string) }, } #[allow(clippy::self_named_constructors)] impl Text { pub fn text(plain: impl Into>) -> Self { Self { content: TextContent::Text { text: plain.into() }, ..Self::default() } } pub fn translate(key: impl Into>) -> Self { Self { content: TextContent::Translate { translate: key.into(), }, ..Self::default() } } pub fn to_plain(&self) -> String { let mut res = String::new(); self.write_plain(&mut res) .expect("failed to write plain text"); res } pub fn write_plain(&self, w: &mut impl fmt::Write) -> fmt::Result { if let TextContent::Text { text } = &self.content { w.write_str(text.as_ref())?; } for child in &self.extra { child.write_plain(w)?; } Ok(()) } // TODO: getters } impl> TextFormat for T {} impl> std::ops::Add for Text { type Output = Self; fn add(self, rhs: T) -> Self::Output { self.add_child(rhs) } } impl> std::ops::AddAssign for Text { fn add_assign(&mut self, rhs: T) { self.extra.push(rhs.into()); } } impl From for Text { fn from(s: String) -> Self { Text::text(s) } } impl From<&'static str> for Text { fn from(s: &'static str) -> Self { Text::text(s) } } impl From> for Text { fn from(s: Cow<'static, str>) -> Self { Text::text(s) } } impl<'a> From<&'a Text> for String { fn from(t: &'a Text) -> Self { t.to_plain() } } impl<'a, 'b> From<&'a Text> for Cow<'b, str> { fn from(t: &'a Text) -> Self { String::from(t).into() } } impl fmt::Display for Text { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", String::from(self)) } } impl Encode for Text { fn encode(&self, w: &mut impl Write) -> anyhow::Result<()> { BoundedString::<0, 262144>(serde_json::to_string(self)?).encode(w) } } impl Decode for Text { fn decode(r: &mut impl Read) -> anyhow::Result { let string = BoundedString::<0, 262144>::decode(r)?; Ok(serde_json::from_str(&string.0)?) } } impl Default for TextContent { fn default() -> Self { Self::Text { text: "".into() } } } impl Color { pub const AQUA: Color = Color::new(85, 255, 255); pub const BLACK: Color = Color::new(0, 0, 0); pub const BLUE: Color = Color::new(85, 85, 255); pub const DARK_AQUA: Color = Color::new(0, 170, 170); pub const DARK_BLUE: Color = Color::new(0, 0, 170); pub const DARK_GRAY: Color = Color::new(85, 85, 85); pub const DARK_GREEN: Color = Color::new(0, 170, 0); pub const DARK_PURPLE: Color = Color::new(170, 0, 170); pub const DARK_RED: Color = Color::new(170, 0, 0); pub const GOLD: Color = Color::new(255, 170, 0); pub const GRAY: Color = Color::new(170, 170, 170); pub const GREEN: Color = Color::new(85, 255, 85); pub const LIGHT_PURPLE: Color = Color::new(255, 85, 255); pub const RED: Color = Color::new(255, 85, 85); pub const WHITE: Color = Color::new(255, 255, 255); pub const YELLOW: Color = Color::new(255, 255, 85); pub const fn new(r: u8, g: u8, b: u8) -> Self { Self { r, g, b } } } impl Serialize for Color { fn serialize(&self, serializer: S) -> Result { format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b).serialize(serializer) } } impl<'de> Deserialize<'de> for Color { fn deserialize>(deserializer: D) -> Result { deserializer.deserialize_str(ColorVisitor) } } struct ColorVisitor; impl<'de> Visitor<'de> for ColorVisitor { type Value = Color; fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "a hex color of the form #rrggbb") } fn visit_str(self, s: &str) -> Result { color_from_str(s).ok_or_else(|| E::custom("invalid hex color")) } } fn color_from_str(s: &str) -> Option { let to_num = |d| match d { b'0'..=b'9' => Some(d - b'0'), b'a'..=b'f' => Some(d - b'a' + 0xa), b'A'..=b'F' => Some(d - b'A' + 0xa), _ => None, }; match s.as_bytes() { [b'#', r0, r1, g0, g1, b0, b1] => Some(Color { r: to_num(*r0)? << 4 | to_num(*r1)?, g: to_num(*g0)? << 4 | to_num(*g1)?, b: to_num(*b0)? << 4 | to_num(*b1)?, }), _ => None, } } #[cfg(test)] mod tests { use super::*; #[test] fn serialize_deserialize() { let before = "foo".color(Color::RED).bold() + ("bar".obfuscated().color(Color::YELLOW) + "baz".underlined().not_bold().italic().color(Color::BLACK)); assert_eq!(before.to_plain(), "foobarbaz"); let json = serde_json::to_string_pretty(&before).unwrap(); let after: Text = serde_json::from_str(&json).unwrap(); println!("==== Before ====\n"); println!("{before:#?}"); println!("==== After ====\n"); println!("{after:#?}"); assert_eq!(before, after); assert_eq!(before.to_plain(), after.to_plain()); } #[test] fn color() { assert_eq!( color_from_str("#aBcDeF"), Some(Color::new(0xab, 0xcd, 0xef)) ); assert_eq!(color_from_str("#fFfFfF"), Some(Color::new(255, 255, 255))); assert_eq!(color_from_str("#00000000"), None); assert_eq!(color_from_str("#000000"), Some(Color::BLACK)); assert_eq!(color_from_str("#"), None); } }