Skip to main content

basalt_types/
text.rs

1use crate::nbt::{NbtCompound, NbtList, NbtTag};
2use crate::{Decode, Encode, EncodedSize, Error, Result};
3
4/// A rich text component used for chat messages, disconnect reasons,
5/// action bars, titles, and all UI text in the Minecraft protocol.
6///
7/// TextComponent is a recursive tree structure: each component has content,
8/// optional styling, and an optional list of child components (`extra`).
9/// Children inherit their parent's style unless they override specific fields.
10///
11/// Since Minecraft 1.20.3, TextComponent is encoded as NBT on the wire
12/// (previously JSON). The `Encode`/`Decode` implementations convert
13/// to/from `NbtCompound` using the network NBT format.
14#[derive(Debug, Clone, PartialEq)]
15pub struct TextComponent {
16    /// The content of this text component (text, translation, keybind, etc.).
17    pub content: TextContent,
18
19    /// Styling applied to this component. `None` fields inherit from the parent.
20    pub style: TextStyle,
21
22    /// Child components appended after this one, inheriting its style.
23    pub extra: Vec<TextComponent>,
24}
25
26/// The content payload of a text component.
27///
28/// Determines what text is displayed. Only one content type is active
29/// per component. The most common is `Text` for literal strings and
30/// `Translate` for server-side localization.
31#[derive(Debug, Clone, PartialEq)]
32pub enum TextContent {
33    /// A literal text string displayed as-is.
34    Text(String),
35
36    /// A translation key resolved by the client's language file.
37    /// `with` contains substitution arguments inserted into the template.
38    Translate {
39        /// The translation key (e.g., `chat.type.text`).
40        key: String,
41        /// Substitution arguments for the translation template.
42        with: Vec<TextComponent>,
43    },
44
45    /// A keybind name resolved to the player's current key binding
46    /// (e.g., `key.jump` displays whatever key the player has bound to jump).
47    Keybind(String),
48
49    /// A scoreboard value resolved by the server.
50    Score {
51        /// The entity selector or player name whose score to display.
52        name: String,
53        /// The scoreboard objective to read from.
54        objective: String,
55    },
56
57    /// An entity selector resolved by the server into matching entity names.
58    Selector(String),
59}
60
61/// Visual styling for a text component.
62///
63/// All fields are `Option` — `None` means the value is inherited from the
64/// parent component. The root component inherits from the client's default
65/// chat style (typically white, non-bold, non-italic).
66#[derive(Debug, Clone, Default, PartialEq)]
67pub struct TextStyle {
68    /// The text color. Overrides the parent's color when set.
69    pub color: Option<TextColor>,
70    /// Bold formatting. Renders the text with a thicker stroke.
71    pub bold: Option<bool>,
72    /// Italic formatting. Renders the text at a slant.
73    pub italic: Option<bool>,
74    /// Underlined formatting. Draws a line beneath the text.
75    pub underlined: Option<bool>,
76    /// Strikethrough formatting. Draws a line through the text.
77    pub strikethrough: Option<bool>,
78    /// Obfuscated formatting. Rapidly cycles through random characters.
79    pub obfuscated: Option<bool>,
80    /// Text inserted into the chat input when the component is shift-clicked.
81    pub insertion: Option<String>,
82    /// Action triggered when the component is clicked.
83    pub click_event: Option<ClickEvent>,
84    /// Content displayed when the component is hovered.
85    pub hover_event: Option<HoverEvent>,
86}
87
88/// Text color, either a named Minecraft color or an arbitrary hex RGB value.
89#[derive(Debug, Clone, PartialEq)]
90pub enum TextColor {
91    /// One of the 16 built-in Minecraft chat colors.
92    Named(NamedColor),
93    /// An arbitrary RGB color specified as a `#RRGGBB` hex string.
94    /// Stored as the raw 24-bit integer (0x000000 to 0xFFFFFF).
95    Hex(u32),
96}
97
98/// The 16 built-in Minecraft chat colors plus reset.
99///
100/// Each color maps to a specific RGB value in the client's rendering.
101/// These names are used in the NBT `color` field as lowercase strings.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
103pub enum NamedColor {
104    Black,
105    DarkBlue,
106    DarkGreen,
107    DarkAqua,
108    DarkRed,
109    DarkPurple,
110    Gold,
111    Gray,
112    DarkGray,
113    Blue,
114    Green,
115    Aqua,
116    Red,
117    LightPurple,
118    Yellow,
119    White,
120}
121
122impl NamedColor {
123    /// Returns the NBT string representation of this color.
124    fn as_str(&self) -> &'static str {
125        match self {
126            Self::Black => "black",
127            Self::DarkBlue => "dark_blue",
128            Self::DarkGreen => "dark_green",
129            Self::DarkAqua => "dark_aqua",
130            Self::DarkRed => "dark_red",
131            Self::DarkPurple => "dark_purple",
132            Self::Gold => "gold",
133            Self::Gray => "gray",
134            Self::DarkGray => "dark_gray",
135            Self::Blue => "blue",
136            Self::Green => "green",
137            Self::Aqua => "aqua",
138            Self::Red => "red",
139            Self::LightPurple => "light_purple",
140            Self::Yellow => "yellow",
141            Self::White => "white",
142        }
143    }
144
145    /// Parses a named color from its NBT string representation.
146    fn from_str(s: &str) -> Option<Self> {
147        match s {
148            "black" => Some(Self::Black),
149            "dark_blue" => Some(Self::DarkBlue),
150            "dark_green" => Some(Self::DarkGreen),
151            "dark_aqua" => Some(Self::DarkAqua),
152            "dark_red" => Some(Self::DarkRed),
153            "dark_purple" => Some(Self::DarkPurple),
154            "gold" => Some(Self::Gold),
155            "gray" => Some(Self::Gray),
156            "dark_gray" => Some(Self::DarkGray),
157            "blue" => Some(Self::Blue),
158            "green" => Some(Self::Green),
159            "aqua" => Some(Self::Aqua),
160            "red" => Some(Self::Red),
161            "light_purple" => Some(Self::LightPurple),
162            "yellow" => Some(Self::Yellow),
163            "white" => Some(Self::White),
164            _ => None,
165        }
166    }
167}
168
169/// An action triggered when the player clicks a text component.
170#[derive(Debug, Clone, PartialEq)]
171pub enum ClickEvent {
172    /// Opens the given URL in the player's browser.
173    OpenUrl(String),
174    /// Sends the given string as a chat command.
175    RunCommand(String),
176    /// Inserts the given string into the chat input without sending.
177    SuggestCommand(String),
178    /// Copies the given string to the clipboard.
179    CopyToClipboard(String),
180}
181
182/// Content displayed when the player hovers over a text component.
183#[derive(Debug, Clone, PartialEq)]
184pub enum HoverEvent {
185    /// Shows another text component as a tooltip.
186    ShowText(Box<TextComponent>),
187    /// Shows an item tooltip with ID, count, and optional NBT tag.
188    ShowItem {
189        /// The item identifier (e.g., `minecraft:diamond_sword`).
190        id: String,
191        /// The item stack count.
192        count: i32,
193        /// Optional item NBT data as a serialized string.
194        tag: Option<String>,
195    },
196    /// Shows an entity tooltip with UUID, type, and optional custom name.
197    ShowEntity {
198        /// The entity's UUID as a string.
199        id: String,
200        /// The entity type identifier (e.g., `minecraft:creeper`).
201        type_id: String,
202        /// The entity's custom name, if any.
203        name: Option<Box<TextComponent>>,
204    },
205}
206
207// -- Convenience constructors --
208
209impl TextComponent {
210    /// Creates a plain text component with no styling.
211    ///
212    /// This is the most common way to create a text component for simple
213    /// messages. The text is displayed as-is with the default chat style.
214    pub fn text(text: impl Into<String>) -> Self {
215        Self {
216            content: TextContent::Text(text.into()),
217            style: TextStyle::default(),
218            extra: Vec::new(),
219        }
220    }
221
222    /// Creates a translation component with substitution arguments.
223    ///
224    /// The client resolves the key against its language file and inserts
225    /// the `with` components at the template's substitution points.
226    pub fn translate(key: impl Into<String>, with: Vec<TextComponent>) -> Self {
227        Self {
228            content: TextContent::Translate {
229                key: key.into(),
230                with,
231            },
232            style: TextStyle::default(),
233            extra: Vec::new(),
234        }
235    }
236
237    /// Sets the text color. Returns self for builder-style chaining.
238    pub fn color(mut self, color: TextColor) -> Self {
239        self.style.color = Some(color);
240        self
241    }
242
243    /// Sets bold formatting. Returns self for builder-style chaining.
244    pub fn bold(mut self, bold: bool) -> Self {
245        self.style.bold = Some(bold);
246        self
247    }
248
249    /// Sets italic formatting. Returns self for builder-style chaining.
250    pub fn italic(mut self, italic: bool) -> Self {
251        self.style.italic = Some(italic);
252        self
253    }
254
255    /// Sets underlined formatting. Returns self for builder-style chaining.
256    pub fn underlined(mut self, underlined: bool) -> Self {
257        self.style.underlined = Some(underlined);
258        self
259    }
260
261    /// Sets strikethrough formatting. Returns self for builder-style chaining.
262    pub fn strikethrough(mut self, strikethrough: bool) -> Self {
263        self.style.strikethrough = Some(strikethrough);
264        self
265    }
266
267    /// Sets obfuscated formatting. Returns self for builder-style chaining.
268    pub fn obfuscated(mut self, obfuscated: bool) -> Self {
269        self.style.obfuscated = Some(obfuscated);
270        self
271    }
272
273    /// Sets the click event. Returns self for builder-style chaining.
274    pub fn click_event(mut self, event: ClickEvent) -> Self {
275        self.style.click_event = Some(event);
276        self
277    }
278
279    /// Sets the hover event. Returns self for builder-style chaining.
280    pub fn hover_event(mut self, event: HoverEvent) -> Self {
281        self.style.hover_event = Some(event);
282        self
283    }
284
285    /// Appends a child component. Returns self for builder-style chaining.
286    pub fn append(mut self, child: TextComponent) -> Self {
287        self.extra.push(child);
288        self
289    }
290
291    /// Converts this text component into an `NbtCompound`.
292    ///
293    /// Useful for protocol packets that accept an `NbtCompound` directly
294    /// (e.g., `SystemChat`, `KickDisconnect`, title packets) instead of
295    /// going through the `Encode` trait.
296    pub fn to_nbt(&self) -> NbtCompound {
297        component_to_nbt(self)
298    }
299}
300
301// -- NBT conversion --
302
303/// Converts a TextComponent into an NbtCompound for wire encoding.
304fn component_to_nbt(component: &TextComponent) -> NbtCompound {
305    let mut nbt = NbtCompound::new();
306
307    // Content
308    match &component.content {
309        TextContent::Text(text) => {
310            nbt.insert("text", NbtTag::String(text.clone()));
311        }
312        TextContent::Translate { key, with } => {
313            nbt.insert("translate", NbtTag::String(key.clone()));
314            if !with.is_empty() {
315                let list_tags: Vec<NbtTag> = with
316                    .iter()
317                    .map(|c| NbtTag::Compound(component_to_nbt(c)))
318                    .collect();
319                let list = NbtList::from_tags(list_tags).unwrap();
320                nbt.insert("with", NbtTag::List(list));
321            }
322        }
323        TextContent::Keybind(key) => {
324            nbt.insert("keybind", NbtTag::String(key.clone()));
325        }
326        TextContent::Score { name, objective } => {
327            let mut score = NbtCompound::new();
328            score.insert("name", NbtTag::String(name.clone()));
329            score.insert("objective", NbtTag::String(objective.clone()));
330            nbt.insert("score", NbtTag::Compound(score));
331        }
332        TextContent::Selector(selector) => {
333            nbt.insert("selector", NbtTag::String(selector.clone()));
334        }
335    }
336
337    // Style
338    let style = &component.style;
339    if let Some(color) = &style.color {
340        let color_str = match color {
341            TextColor::Named(named) => named.as_str().to_string(),
342            TextColor::Hex(rgb) => format!("#{rgb:06x}"),
343        };
344        nbt.insert("color", NbtTag::String(color_str));
345    }
346    if let Some(bold) = style.bold {
347        nbt.insert("bold", NbtTag::Byte(bold as i8));
348    }
349    if let Some(italic) = style.italic {
350        nbt.insert("italic", NbtTag::Byte(italic as i8));
351    }
352    if let Some(underlined) = style.underlined {
353        nbt.insert("underlined", NbtTag::Byte(underlined as i8));
354    }
355    if let Some(strikethrough) = style.strikethrough {
356        nbt.insert("strikethrough", NbtTag::Byte(strikethrough as i8));
357    }
358    if let Some(obfuscated) = style.obfuscated {
359        nbt.insert("obfuscated", NbtTag::Byte(obfuscated as i8));
360    }
361    if let Some(insertion) = &style.insertion {
362        nbt.insert("insertion", NbtTag::String(insertion.clone()));
363    }
364    if let Some(click) = &style.click_event {
365        let mut event = NbtCompound::new();
366        let (action, value) = match click {
367            ClickEvent::OpenUrl(url) => ("open_url", url.clone()),
368            ClickEvent::RunCommand(cmd) => ("run_command", cmd.clone()),
369            ClickEvent::SuggestCommand(cmd) => ("suggest_command", cmd.clone()),
370            ClickEvent::CopyToClipboard(text) => ("copy_to_clipboard", text.clone()),
371        };
372        event.insert("action", NbtTag::String(action.into()));
373        event.insert("value", NbtTag::String(value));
374        nbt.insert("clickEvent", NbtTag::Compound(event));
375    }
376    if let Some(hover) = &style.hover_event {
377        let mut event = NbtCompound::new();
378        match hover {
379            HoverEvent::ShowText(text) => {
380                event.insert("action", NbtTag::String("show_text".into()));
381                event.insert("contents", NbtTag::Compound(component_to_nbt(text)));
382            }
383            HoverEvent::ShowItem { id, count, tag } => {
384                event.insert("action", NbtTag::String("show_item".into()));
385                let mut contents = NbtCompound::new();
386                contents.insert("id", NbtTag::String(id.clone()));
387                contents.insert("count", NbtTag::Int(*count));
388                if let Some(tag) = tag {
389                    contents.insert("tag", NbtTag::String(tag.clone()));
390                }
391                event.insert("contents", NbtTag::Compound(contents));
392            }
393            HoverEvent::ShowEntity { id, type_id, name } => {
394                event.insert("action", NbtTag::String("show_entity".into()));
395                let mut contents = NbtCompound::new();
396                contents.insert("type", NbtTag::String(type_id.clone()));
397                contents.insert("id", NbtTag::String(id.clone()));
398                if let Some(name) = name {
399                    contents.insert("name", NbtTag::Compound(component_to_nbt(name)));
400                }
401                event.insert("contents", NbtTag::Compound(contents));
402            }
403        }
404        nbt.insert("hoverEvent", NbtTag::Compound(event));
405    }
406
407    // Extra
408    if !component.extra.is_empty() {
409        let list_tags: Vec<NbtTag> = component
410            .extra
411            .iter()
412            .map(|c| NbtTag::Compound(component_to_nbt(c)))
413            .collect();
414        let list = NbtList::from_tags(list_tags).unwrap();
415        nbt.insert("extra", NbtTag::List(list));
416    }
417
418    nbt
419}
420
421/// Parses a TextComponent from an NbtCompound.
422fn component_from_nbt(nbt: &NbtCompound) -> Result<TextComponent> {
423    // Content — determine type by which key is present
424    let content = if let Some(NbtTag::String(text)) = nbt.get("text") {
425        TextContent::Text(text.clone())
426    } else if let Some(NbtTag::String(key)) = nbt.get("translate") {
427        let with = if let Some(NbtTag::List(list)) = nbt.get("with") {
428            let mut components = Vec::new();
429            for tag in &list.elements {
430                if let NbtTag::Compound(c) = tag {
431                    components.push(component_from_nbt(c)?);
432                }
433            }
434            components
435        } else {
436            Vec::new()
437        };
438        TextContent::Translate {
439            key: key.clone(),
440            with,
441        }
442    } else if let Some(NbtTag::String(key)) = nbt.get("keybind") {
443        TextContent::Keybind(key.clone())
444    } else if let Some(NbtTag::Compound(score)) = nbt.get("score") {
445        let name = match score.get("name") {
446            Some(NbtTag::String(s)) => s.clone(),
447            _ => return Err(Error::Nbt("score missing 'name'".into())),
448        };
449        let objective = match score.get("objective") {
450            Some(NbtTag::String(s)) => s.clone(),
451            _ => return Err(Error::Nbt("score missing 'objective'".into())),
452        };
453        TextContent::Score { name, objective }
454    } else if let Some(NbtTag::String(selector)) = nbt.get("selector") {
455        TextContent::Selector(selector.clone())
456    } else {
457        // Default to empty text if no content key is found
458        TextContent::Text(String::new())
459    };
460
461    // Style
462    let mut style = TextStyle::default();
463
464    if let Some(NbtTag::String(color_str)) = nbt.get("color") {
465        if let Some(named) = NamedColor::from_str(color_str) {
466            style.color = Some(TextColor::Named(named));
467        } else if let Some(hex) = color_str.strip_prefix('#')
468            && let Ok(rgb) = u32::from_str_radix(hex, 16)
469        {
470            style.color = Some(TextColor::Hex(rgb));
471        }
472    }
473
474    fn read_bool(nbt: &NbtCompound, key: &str) -> Option<bool> {
475        match nbt.get(key) {
476            Some(NbtTag::Byte(v)) => Some(*v != 0),
477            _ => None,
478        }
479    }
480
481    style.bold = read_bool(nbt, "bold");
482    style.italic = read_bool(nbt, "italic");
483    style.underlined = read_bool(nbt, "underlined");
484    style.strikethrough = read_bool(nbt, "strikethrough");
485    style.obfuscated = read_bool(nbt, "obfuscated");
486
487    if let Some(NbtTag::String(insertion)) = nbt.get("insertion") {
488        style.insertion = Some(insertion.clone());
489    }
490
491    if let Some(NbtTag::Compound(event)) = nbt.get("clickEvent")
492        && let (Some(NbtTag::String(action)), Some(NbtTag::String(value))) =
493            (event.get("action"), event.get("value"))
494    {
495        style.click_event = match action.as_str() {
496            "open_url" => Some(ClickEvent::OpenUrl(value.clone())),
497            "run_command" => Some(ClickEvent::RunCommand(value.clone())),
498            "suggest_command" => Some(ClickEvent::SuggestCommand(value.clone())),
499            "copy_to_clipboard" => Some(ClickEvent::CopyToClipboard(value.clone())),
500            _ => None,
501        };
502    }
503
504    if let Some(NbtTag::Compound(event)) = nbt.get("hoverEvent")
505        && let Some(NbtTag::String(action)) = event.get("action")
506    {
507        style.hover_event = match action.as_str() {
508            "show_text" => {
509                if let Some(NbtTag::Compound(contents)) = event.get("contents") {
510                    Some(HoverEvent::ShowText(Box::new(component_from_nbt(
511                        contents,
512                    )?)))
513                } else {
514                    None
515                }
516            }
517            "show_item" => {
518                if let Some(NbtTag::Compound(contents)) = event.get("contents") {
519                    let id = match contents.get("id") {
520                        Some(NbtTag::String(s)) => s.clone(),
521                        _ => return Err(Error::Nbt("show_item missing 'id'".into())),
522                    };
523                    let count = match contents.get("count") {
524                        Some(NbtTag::Int(n)) => *n,
525                        _ => 1,
526                    };
527                    let tag = match contents.get("tag") {
528                        Some(NbtTag::String(s)) => Some(s.clone()),
529                        _ => None,
530                    };
531                    Some(HoverEvent::ShowItem { id, count, tag })
532                } else {
533                    None
534                }
535            }
536            "show_entity" => {
537                if let Some(NbtTag::Compound(contents)) = event.get("contents") {
538                    let id = match contents.get("id") {
539                        Some(NbtTag::String(s)) => s.clone(),
540                        _ => return Err(Error::Nbt("show_entity missing 'id'".into())),
541                    };
542                    let type_id = match contents.get("type") {
543                        Some(NbtTag::String(s)) => s.clone(),
544                        _ => return Err(Error::Nbt("show_entity missing 'type'".into())),
545                    };
546                    let name = if let Some(NbtTag::Compound(name_nbt)) = contents.get("name") {
547                        Some(Box::new(component_from_nbt(name_nbt)?))
548                    } else {
549                        None
550                    };
551                    Some(HoverEvent::ShowEntity { id, type_id, name })
552                } else {
553                    None
554                }
555            }
556            _ => None,
557        };
558    }
559
560    // Extra
561    let extra = if let Some(NbtTag::List(list)) = nbt.get("extra") {
562        let mut children = Vec::new();
563        for tag in &list.elements {
564            if let NbtTag::Compound(c) = tag {
565                children.push(component_from_nbt(c)?);
566            }
567        }
568        children
569    } else {
570        Vec::new()
571    };
572
573    Ok(TextComponent {
574        content,
575        style,
576        extra,
577    })
578}
579
580/// Encodes a TextComponent as network NBT (compound tag).
581///
582/// Converts the component tree into an NbtCompound, then encodes it
583/// using the network NBT format (1.20.3+). The resulting bytes can be
584/// used directly in protocol packets for chat, disconnect, title, etc.
585impl Encode for TextComponent {
586    /// Serializes the component to network NBT format.
587    fn encode(&self, buf: &mut Vec<u8>) -> Result<()> {
588        let nbt = component_to_nbt(self);
589        nbt.encode(buf)
590    }
591}
592
593/// Decodes a TextComponent from network NBT (compound tag).
594///
595/// Reads a network NBT compound, then parses it into a TextComponent tree.
596/// Handles all content types (text, translate, keybind, score, selector),
597/// all style fields, click/hover events, and recursive extra children.
598impl Decode for TextComponent {
599    /// Deserializes a TextComponent from network NBT format.
600    ///
601    /// Fails with `Error::Nbt` if required fields are missing or have
602    /// unexpected types.
603    fn decode(buf: &mut &[u8]) -> Result<Self> {
604        let nbt = NbtCompound::decode(buf)?;
605        component_from_nbt(&nbt)
606    }
607}
608
609/// Computes the wire size of a TextComponent as network NBT.
610///
611/// Converts to NbtCompound and delegates to its EncodedSize. This involves
612/// building the full NBT tree, so it is not free — use sparingly or cache
613/// the result when encoding multiple times.
614impl EncodedSize for TextComponent {
615    /// Returns the byte count of the network NBT encoding.
616    fn encoded_size(&self) -> usize {
617        let nbt = component_to_nbt(self);
618        nbt.encoded_size()
619    }
620}
621
622#[cfg(test)]
623mod tests {
624    use super::*;
625
626    fn roundtrip(component: &TextComponent) {
627        let mut buf = Vec::with_capacity(component.encoded_size());
628        component.encode(&mut buf).unwrap();
629        assert_eq!(buf.len(), component.encoded_size());
630
631        let mut cursor = buf.as_slice();
632        let decoded = TextComponent::decode(&mut cursor).unwrap();
633        assert!(cursor.is_empty());
634        assert_eq!(decoded, *component);
635    }
636
637    // -- Plain text --
638
639    #[test]
640    fn plain_text() {
641        roundtrip(&TextComponent::text("hello"));
642    }
643
644    #[test]
645    fn empty_text() {
646        roundtrip(&TextComponent::text(""));
647    }
648
649    // -- Styled text --
650
651    #[test]
652    fn bold_red_text() {
653        let tc = TextComponent::text("warning")
654            .bold(true)
655            .color(TextColor::Named(NamedColor::Red));
656        roundtrip(&tc);
657    }
658
659    #[test]
660    fn all_formatting() {
661        let tc = TextComponent::text("styled")
662            .bold(true)
663            .italic(true)
664            .underlined(true)
665            .strikethrough(true)
666            .obfuscated(true);
667        roundtrip(&tc);
668    }
669
670    #[test]
671    fn hex_color() {
672        let tc = TextComponent::text("custom color").color(TextColor::Hex(0xFF5500));
673        roundtrip(&tc);
674    }
675
676    #[test]
677    fn all_named_colors() {
678        let colors = [
679            NamedColor::Black,
680            NamedColor::DarkBlue,
681            NamedColor::DarkGreen,
682            NamedColor::DarkAqua,
683            NamedColor::DarkRed,
684            NamedColor::DarkPurple,
685            NamedColor::Gold,
686            NamedColor::Gray,
687            NamedColor::DarkGray,
688            NamedColor::Blue,
689            NamedColor::Green,
690            NamedColor::Aqua,
691            NamedColor::Red,
692            NamedColor::LightPurple,
693            NamedColor::Yellow,
694            NamedColor::White,
695        ];
696        for color in colors {
697            let tc = TextComponent::text("test").color(TextColor::Named(color));
698            roundtrip(&tc);
699        }
700    }
701
702    #[test]
703    fn insertion() {
704        let tc = TextComponent {
705            content: TextContent::Text("click me".into()),
706            style: TextStyle {
707                insertion: Some("/help".into()),
708                ..Default::default()
709            },
710            extra: Vec::new(),
711        };
712        roundtrip(&tc);
713    }
714
715    // -- Extra children --
716
717    #[test]
718    fn with_extra() {
719        let tc = TextComponent::text("hello ").append(TextComponent::text("world").bold(true));
720        roundtrip(&tc);
721    }
722
723    #[test]
724    fn nested_extra() {
725        let tc = TextComponent::text("a")
726            .append(TextComponent::text("b").append(TextComponent::text("c").italic(true)));
727        roundtrip(&tc);
728    }
729
730    // -- Content types --
731
732    #[test]
733    fn translate_no_args() {
734        let tc = TextComponent::translate("multiplayer.disconnect.kicked", vec![]);
735        roundtrip(&tc);
736    }
737
738    #[test]
739    fn translate_with_args() {
740        let tc = TextComponent::translate(
741            "chat.type.text",
742            vec![
743                TextComponent::text("Player1"),
744                TextComponent::text("Hello!"),
745            ],
746        );
747        roundtrip(&tc);
748    }
749
750    #[test]
751    fn keybind() {
752        let tc = TextComponent {
753            content: TextContent::Keybind("key.jump".into()),
754            style: TextStyle::default(),
755            extra: Vec::new(),
756        };
757        roundtrip(&tc);
758    }
759
760    #[test]
761    fn score() {
762        let tc = TextComponent {
763            content: TextContent::Score {
764                name: "Player1".into(),
765                objective: "kills".into(),
766            },
767            style: TextStyle::default(),
768            extra: Vec::new(),
769        };
770        roundtrip(&tc);
771    }
772
773    #[test]
774    fn selector() {
775        let tc = TextComponent {
776            content: TextContent::Selector("@a[distance=..10]".into()),
777            style: TextStyle::default(),
778            extra: Vec::new(),
779        };
780        roundtrip(&tc);
781    }
782
783    // -- Click events --
784
785    #[test]
786    fn click_open_url() {
787        let tc = TextComponent::text("click here")
788            .click_event(ClickEvent::OpenUrl("https://minecraft.net".into()));
789        roundtrip(&tc);
790    }
791
792    #[test]
793    fn click_run_command() {
794        let tc = TextComponent::text("run")
795            .click_event(ClickEvent::RunCommand("/gamemode creative".into()));
796        roundtrip(&tc);
797    }
798
799    #[test]
800    fn click_suggest_command() {
801        let tc =
802            TextComponent::text("suggest").click_event(ClickEvent::SuggestCommand("/tp ".into()));
803        roundtrip(&tc);
804    }
805
806    #[test]
807    fn click_copy() {
808        let tc =
809            TextComponent::text("copy").click_event(ClickEvent::CopyToClipboard("secret".into()));
810        roundtrip(&tc);
811    }
812
813    // -- Hover events --
814
815    #[test]
816    fn hover_show_text() {
817        let tc = TextComponent::text("hover me").hover_event(HoverEvent::ShowText(Box::new(
818            TextComponent::text("tooltip").color(TextColor::Named(NamedColor::Yellow)),
819        )));
820        roundtrip(&tc);
821    }
822
823    #[test]
824    fn hover_show_item() {
825        let tc = TextComponent::text("item").hover_event(HoverEvent::ShowItem {
826            id: "minecraft:diamond_sword".into(),
827            count: 1,
828            tag: Some("{Damage:10}".into()),
829        });
830        roundtrip(&tc);
831    }
832
833    #[test]
834    fn hover_show_item_no_tag() {
835        let tc = TextComponent::text("item").hover_event(HoverEvent::ShowItem {
836            id: "minecraft:stone".into(),
837            count: 64,
838            tag: None,
839        });
840        roundtrip(&tc);
841    }
842
843    #[test]
844    fn hover_show_entity() {
845        let tc = TextComponent::text("entity").hover_event(HoverEvent::ShowEntity {
846            id: "550e8400-e29b-41d4-a716-446655440000".into(),
847            type_id: "minecraft:creeper".into(),
848            name: Some(Box::new(
849                TextComponent::text("Bob").color(TextColor::Named(NamedColor::Green)),
850            )),
851        });
852        roundtrip(&tc);
853    }
854
855    #[test]
856    fn hover_show_entity_no_name() {
857        let tc = TextComponent::text("entity").hover_event(HoverEvent::ShowEntity {
858            id: "550e8400-e29b-41d4-a716-446655440000".into(),
859            type_id: "minecraft:zombie".into(),
860            name: None,
861        });
862        roundtrip(&tc);
863    }
864
865    // -- Complex --
866
867    #[test]
868    fn complex_component() {
869        let tc = TextComponent::text("[")
870            .color(TextColor::Named(NamedColor::Gray))
871            .append(
872                TextComponent::text("Server")
873                    .color(TextColor::Named(NamedColor::Gold))
874                    .bold(true),
875            )
876            .append(TextComponent::text("] ").color(TextColor::Named(NamedColor::Gray)))
877            .append(
878                TextComponent::text("Welcome!")
879                    .color(TextColor::Named(NamedColor::White))
880                    .click_event(ClickEvent::RunCommand("/help".into()))
881                    .hover_event(HoverEvent::ShowText(Box::new(TextComponent::text(
882                        "Click for help",
883                    )))),
884            );
885        roundtrip(&tc);
886    }
887}