1use crate::nbt::{NbtCompound, NbtList, NbtTag};
2use crate::{Decode, Encode, EncodedSize, Error, Result};
3
4#[derive(Debug, Clone, PartialEq)]
15pub struct TextComponent {
16 pub content: TextContent,
18
19 pub style: TextStyle,
21
22 pub extra: Vec<TextComponent>,
24}
25
26#[derive(Debug, Clone, PartialEq)]
32pub enum TextContent {
33 Text(String),
35
36 Translate {
39 key: String,
41 with: Vec<TextComponent>,
43 },
44
45 Keybind(String),
48
49 Score {
51 name: String,
53 objective: String,
55 },
56
57 Selector(String),
59}
60
61#[derive(Debug, Clone, Default, PartialEq)]
67pub struct TextStyle {
68 pub color: Option<TextColor>,
70 pub bold: Option<bool>,
72 pub italic: Option<bool>,
74 pub underlined: Option<bool>,
76 pub strikethrough: Option<bool>,
78 pub obfuscated: Option<bool>,
80 pub insertion: Option<String>,
82 pub click_event: Option<ClickEvent>,
84 pub hover_event: Option<HoverEvent>,
86}
87
88#[derive(Debug, Clone, PartialEq)]
90pub enum TextColor {
91 Named(NamedColor),
93 Hex(u32),
96}
97
98#[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 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 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#[derive(Debug, Clone, PartialEq)]
171pub enum ClickEvent {
172 OpenUrl(String),
174 RunCommand(String),
176 SuggestCommand(String),
178 CopyToClipboard(String),
180}
181
182#[derive(Debug, Clone, PartialEq)]
184pub enum HoverEvent {
185 ShowText(Box<TextComponent>),
187 ShowItem {
189 id: String,
191 count: i32,
193 tag: Option<String>,
195 },
196 ShowEntity {
198 id: String,
200 type_id: String,
202 name: Option<Box<TextComponent>>,
204 },
205}
206
207impl TextComponent {
210 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 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 pub fn color(mut self, color: TextColor) -> Self {
239 self.style.color = Some(color);
240 self
241 }
242
243 pub fn bold(mut self, bold: bool) -> Self {
245 self.style.bold = Some(bold);
246 self
247 }
248
249 pub fn italic(mut self, italic: bool) -> Self {
251 self.style.italic = Some(italic);
252 self
253 }
254
255 pub fn underlined(mut self, underlined: bool) -> Self {
257 self.style.underlined = Some(underlined);
258 self
259 }
260
261 pub fn strikethrough(mut self, strikethrough: bool) -> Self {
263 self.style.strikethrough = Some(strikethrough);
264 self
265 }
266
267 pub fn obfuscated(mut self, obfuscated: bool) -> Self {
269 self.style.obfuscated = Some(obfuscated);
270 self
271 }
272
273 pub fn click_event(mut self, event: ClickEvent) -> Self {
275 self.style.click_event = Some(event);
276 self
277 }
278
279 pub fn hover_event(mut self, event: HoverEvent) -> Self {
281 self.style.hover_event = Some(event);
282 self
283 }
284
285 pub fn append(mut self, child: TextComponent) -> Self {
287 self.extra.push(child);
288 self
289 }
290
291 pub fn to_nbt(&self) -> NbtCompound {
297 component_to_nbt(self)
298 }
299}
300
301fn component_to_nbt(component: &TextComponent) -> NbtCompound {
305 let mut nbt = NbtCompound::new();
306
307 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 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 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
421fn component_from_nbt(nbt: &NbtCompound) -> Result<TextComponent> {
423 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 TextContent::Text(String::new())
459 };
460
461 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 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
580impl Encode for TextComponent {
586 fn encode(&self, buf: &mut Vec<u8>) -> Result<()> {
588 let nbt = component_to_nbt(self);
589 nbt.encode(buf)
590 }
591}
592
593impl Decode for TextComponent {
599 fn decode(buf: &mut &[u8]) -> Result<Self> {
604 let nbt = NbtCompound::decode(buf)?;
605 component_from_nbt(&nbt)
606 }
607}
608
609impl EncodedSize for TextComponent {
615 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}