mirror of
https://github.com/italicsjenga/valence.git
synced 2024-12-23 22:41:30 +11:00
587 lines
15 KiB
Rust
587 lines
15 KiB
Rust
//! 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<Color>,
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
font: Option<Cow<'static, str>>,
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
bold: Option<bool>,
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
italic: Option<bool>,
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
underlined: Option<bool>,
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
strikethrough: Option<bool>,
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
obfuscated: Option<bool>,
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
insertion: Option<Cow<'static, str>>,
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
click_event: Option<ClickEvent>,
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
hover_event: Option<HoverEvent>,
|
|
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
extra: Vec<Text>,
|
|
}
|
|
|
|
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<Text>` types without having to first
|
|
/// convert the type into [`Text`]. A blanket implementation exists for all
|
|
/// `Into<Text>` types, including [`Text`] itself.
|
|
pub trait TextFormat: Into<Text> {
|
|
/// 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<Cow<'static, str>>) -> 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<Cow<'static, str>>) -> 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<Cow<'static, str>>) -> Text {
|
|
let mut t = self.into();
|
|
t.click_event = Some(ClickEvent::OpenUrl(url.into()));
|
|
t
|
|
}
|
|
|
|
fn on_click_run_command(self, command: impl Into<Cow<'static, str>>) -> Text {
|
|
let mut t = self.into();
|
|
t.click_event = Some(ClickEvent::RunCommand(command.into()));
|
|
t
|
|
}
|
|
|
|
fn on_click_suggest_command(self, command: impl Into<Cow<'static, str>>) -> Text {
|
|
let mut t = self.into();
|
|
t.click_event = Some(ClickEvent::SuggestCommand(command.into()));
|
|
t
|
|
}
|
|
|
|
fn on_click_change_page(self, page: impl Into<i32>) -> 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<Cow<'static, str>>) -> 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>) -> 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>) -> 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<Text>),
|
|
ShowItem {
|
|
id: Ident,
|
|
count: Option<i32>,
|
|
// TODO: tag
|
|
},
|
|
ShowEntity {
|
|
name: Box<Text>,
|
|
#[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<Cow<'static, str>>) -> 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<Cow<'static, str>>) -> 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<T: Into<Text>> TextFormat for T {}
|
|
|
|
impl<T: Into<Text>> std::ops::Add<T> for Text {
|
|
type Output = Self;
|
|
|
|
fn add(self, rhs: T) -> Self::Output {
|
|
self.add_child(rhs)
|
|
}
|
|
}
|
|
|
|
impl<T: Into<Text>> std::ops::AddAssign<T> for Text {
|
|
fn add_assign(&mut self, rhs: T) {
|
|
self.extra.push(rhs.into());
|
|
}
|
|
}
|
|
|
|
impl From<char> for Text {
|
|
fn from(c: char) -> Self {
|
|
Text::text(String::from(c))
|
|
}
|
|
}
|
|
|
|
impl From<String> 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<Cow<'static, str>> 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<Self> {
|
|
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<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
|
format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b).serialize(serializer)
|
|
}
|
|
}
|
|
|
|
impl<'de> Deserialize<'de> for Color {
|
|
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
|
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<E: serde::de::Error>(self, s: &str) -> Result<Self::Value, E> {
|
|
color_from_str(s).ok_or_else(|| E::custom("invalid hex color"))
|
|
}
|
|
}
|
|
|
|
fn color_from_str(s: &str) -> Option<Color> {
|
|
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());
|
|
}
|
|
}
|