//! Formatted text. use std::borrow::Cow; use std::fmt; use std::io::{Read, Write}; use serde::de::Visitor; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use crate::ident::Ident; use crate::protocol_inner::{BoundedString, Decode, Encode}; /// 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]. /// /// Note that the current `Deserialize` implementation on this type recognizes /// only a subset of the full JSON chat component format. /// /// [Minecraft Wiki article]: https://minecraft.fandom.com/wiki/Raw_JSON_text_format /// /// # Examples /// /// 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, } impl Text { /// Returns `true` if the text contains no characters. Returns `false` /// otherwise. pub fn is_empty(&self) -> bool { for extra in &self.extra { if !extra.is_empty() { return false; } } match &self.content { TextContent::Text { text } => text.is_empty(), TextContent::Translate { translate } => translate.is_empty(), } } } /// 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`]. A blanket implementation exists for all /// `Into` types, including [`Text`] itself. pub trait TextFormat: Into { /// Converts this type into a [`Text`] object. 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 } /// Text color #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] pub struct Color { /// Red channel pub r: u8, /// Green channel pub g: u8, /// Blue channel 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")] kind: Ident, // TODO: id (hyphenated entity UUID as a string) }, } #[allow(clippy::self_named_constructors)] impl Text { /// Constructs a new plain text object. pub fn text(plain: impl Into>) -> Self { Self { content: TextContent::Text { text: plain.into() }, ..Self::default() } } /// Create translated text based on the given translation key. pub fn translate(key: impl Into>) -> Self { Self { content: TextContent::Translate { translate: key.into(), }, ..Self::default() } } /// Gets this text object as plain text without any formatting. pub fn to_plain(&self) -> String { let mut res = String::new(); self.write_plain(&mut res) .expect("failed to write plain text"); res } /// Writes this text object as plain text to the provided writer. pub fn write_plain(&self, w: &mut impl fmt::Write) -> fmt::Result { match &self.content { TextContent::Text { text } => w.write_str(text.as_ref())?, TextContent::Translate { translate } => w.write_str(translate.as_ref())?, } for child in &self.extra { child.write_plain(w)?; } Ok(()) } } 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(c: char) -> Self { Text::text(String::from(c)) } } 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() } } } #[allow(missing_docs)] 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); /// Constructs a new color from red, green, and blue components. 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); } #[test] fn empty() { assert!("".into_text().is_empty()); let txt = "".into_text() + Text::translate("") + ("".italic().color(Color::RED) + ""); assert!(txt.is_empty()); assert!(txt.to_plain().is_empty()); } }