Skip to main content

steel_registry/data_components/components/
sign_text.rs

1//! Vanilla `minecraft:sign_text_front` / `minecraft:sign_text_back` item components.
2//!
3//! Mirrors vanilla's `SignText.CODEC`/`STREAM_CODEC`: `messages` is a fixed
4//! 4-entry list, `filtered_messages` is only present when it differs from
5//! `messages` (`lenientOptionalFieldOf`), `color` defaults to black and
6//! `has_glowing_text` defaults to false (`optionalAlwaysPresentFieldOf`).
7
8use std::io::{Cursor, Result, Write};
9
10use simdnbt::owned::{NbtCompound, NbtList, NbtTag};
11use simdnbt::{FromNbtTag, ToNbtTag};
12use steel_utils::hash::{ComponentHasher, HashComponent, HashEntry};
13use steel_utils::serial::{ReadFrom, WriteTo};
14use text_components::TextComponent;
15
16use crate::DyeColor;
17
18/// Number of text lines on one side of a sign.
19pub const SIGN_LINES: usize = 4;
20
21/// Pre-filled text for one side of a sign, carried as an item component
22/// (`minecraft:sign_text_front`/`minecraft:sign_text_back`).
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct SignText {
25    pub messages: [TextComponent; SIGN_LINES],
26    pub filtered_messages: Option<[TextComponent; SIGN_LINES]>,
27    pub color: DyeColor,
28    pub has_glowing_text: bool,
29}
30
31impl SignText {
32    #[must_use]
33    pub const fn new(
34        messages: [TextComponent; SIGN_LINES],
35        filtered_messages: Option<[TextComponent; SIGN_LINES]>,
36        color: DyeColor,
37        has_glowing_text: bool,
38    ) -> Self {
39        Self {
40            messages,
41            filtered_messages,
42            color,
43            has_glowing_text,
44        }
45    }
46}
47
48impl WriteTo for SignText {
49    fn write(&self, writer: &mut impl Write) -> Result<()> {
50        self.messages.write(writer)?;
51        self.filtered_messages.write(writer)?;
52        self.color.write(writer)?;
53        self.has_glowing_text.write(writer)
54    }
55}
56
57impl ReadFrom for SignText {
58    fn read(data: &mut Cursor<&[u8]>) -> Result<Self> {
59        Ok(Self {
60            messages: <[TextComponent; SIGN_LINES]>::read(data)?,
61            filtered_messages: Option::<[TextComponent; SIGN_LINES]>::read(data)?,
62            color: DyeColor::read(data)?,
63            has_glowing_text: bool::read(data)?,
64        })
65    }
66}
67
68fn messages_to_nbt(messages: &[TextComponent; SIGN_LINES]) -> NbtTag {
69    NbtTag::List(NbtList::from(
70        messages
71            .iter()
72            .map(TextComponent::to_codec_nbt)
73            .collect::<Vec<_>>(),
74    ))
75}
76
77fn messages_from_nbt(tag: simdnbt::borrow::NbtTag) -> Option<[TextComponent; SIGN_LINES]> {
78    let tags = tag.list()?.to_owned().as_nbt_tags();
79    let messages = tags
80        .iter()
81        .map(TextComponent::from_nbt)
82        .collect::<Option<Vec<_>>>()?;
83    <[TextComponent; SIGN_LINES]>::try_from(messages).ok()
84}
85
86impl ToNbtTag for SignText {
87    fn to_nbt_tag(self) -> NbtTag {
88        let mut compound = NbtCompound::new();
89        compound.insert("messages", messages_to_nbt(&self.messages));
90        // `lenientOptionalFieldOf`: only serialize `filtered_messages` when it
91        // differs from `messages` (matches `filteredMessagesForSerialization`).
92        if let Some(filtered) = &self.filtered_messages
93            && filtered != &self.messages
94        {
95            compound.insert("filtered_messages", messages_to_nbt(filtered));
96        }
97        compound.insert("color", self.color.serialized_name());
98        compound.insert("has_glowing_text", self.has_glowing_text);
99        NbtTag::Compound(compound)
100    }
101}
102
103impl FromNbtTag for SignText {
104    fn from_nbt_tag(tag: simdnbt::borrow::NbtTag) -> Option<Self> {
105        let compound = tag.compound()?;
106        let messages = messages_from_nbt(compound.get("messages")?)?;
107        // `SignText::load`: absent `filtered_messages` falls back to `messages`.
108        let filtered_messages = compound
109            .get("filtered_messages")
110            .and_then(messages_from_nbt)
111            .unwrap_or_else(|| messages.clone());
112        let color = compound
113            .get("color")
114            .and_then(DyeColor::from_nbt_tag)
115            .unwrap_or(DyeColor::Black);
116        let has_glowing_text = compound
117            .get("has_glowing_text")
118            .and_then(|tag| tag.byte())
119            .is_some_and(|value| value != 0);
120        Some(Self {
121            messages,
122            filtered_messages: Some(filtered_messages),
123            color,
124            has_glowing_text,
125        })
126    }
127}
128
129impl HashComponent for SignText {
130    fn hash_component(&self, hasher: &mut ComponentHasher) {
131        let mut entries = Vec::with_capacity(4);
132        entries.push(messages_hash_entry("messages", &self.messages));
133        // The hash mirrors the persistent codec: `filtered_messages` is only
134        // present in the map when it differs from `messages`.
135        if let Some(filtered) = &self.filtered_messages
136            && filtered != &self.messages
137        {
138            entries.push(messages_hash_entry("filtered_messages", filtered));
139        }
140        push_hash_entry(&mut entries, "color", self.color.serialized_name());
141        push_hash_entry(&mut entries, "has_glowing_text", &self.has_glowing_text);
142
143        hasher.start_map();
144        for entry in entries {
145            hasher.put_raw_bytes(&entry.key_bytes);
146            hasher.put_raw_bytes(&entry.value_bytes);
147        }
148        hasher.end_map();
149    }
150}
151
152fn messages_hash_entry(key: &str, messages: &[TextComponent; SIGN_LINES]) -> HashEntry {
153    let mut key_hasher = ComponentHasher::new();
154    key_hasher.put_string(key);
155    let mut value_hasher = ComponentHasher::new();
156    value_hasher.start_list();
157    for message in messages {
158        message.hash_component(&mut value_hasher);
159    }
160    value_hasher.end_list();
161    HashEntry::new(key_hasher, value_hasher)
162}
163
164fn push_hash_entry<T: HashComponent + ?Sized>(entries: &mut Vec<HashEntry>, key: &str, value: &T) {
165    let mut key_hasher = ComponentHasher::new();
166    key_hasher.put_string(key);
167    let mut value_hasher = ComponentHasher::new();
168    value.hash_component(&mut value_hasher);
169    entries.push(HashEntry::new(key_hasher, value_hasher));
170}
171
172#[cfg(test)]
173mod tests {
174    use std::io::Cursor;
175
176    use simdnbt::FromNbtTag as _;
177    use simdnbt::ToNbtTag as _;
178    use simdnbt::borrow::read_tag;
179    use simdnbt::owned::NbtTag;
180    use steel_utils::serial::{ReadFrom as _, WriteTo as _};
181    use text_components::TextComponent;
182
183    use super::SignText;
184    use crate::DyeColor;
185
186    fn parse(tag: NbtTag) -> Option<SignText> {
187        let mut bytes = Vec::new();
188        tag.write(&mut bytes);
189        let borrowed = read_tag(&mut Cursor::new(bytes.as_slice())).ok()?;
190        SignText::from_nbt_tag(borrowed.as_tag())
191    }
192
193    fn empty_messages() -> [TextComponent; 4] {
194        std::array::from_fn(|_| TextComponent::plain(""))
195    }
196
197    #[test]
198    fn round_trips_both_codecs_without_filtered_messages() {
199        let value = SignText::new(empty_messages(), None, DyeColor::Black, false);
200
201        let nbt = value.clone().to_nbt_tag();
202        // Absent `filtered_messages` on decode falls back to `messages`.
203        let expected = SignText::new(
204            empty_messages(),
205            Some(empty_messages()),
206            DyeColor::Black,
207            false,
208        );
209        assert_eq!(parse(nbt), Some(expected));
210
211        let mut network = Vec::new();
212        value.write(&mut network).expect("sign text should encode");
213        assert_eq!(
214            SignText::read(&mut Cursor::new(network.as_slice())).expect("sign text should decode"),
215            value
216        );
217    }
218
219    #[test]
220    fn filtered_messages_equal_to_messages_are_not_persisted() {
221        let value = SignText::new(
222            empty_messages(),
223            Some(empty_messages()),
224            DyeColor::Black,
225            false,
226        );
227
228        let nbt = value.clone().to_nbt_tag();
229        let NbtTag::Compound(compound) = &nbt else {
230            panic!("sign text should encode as a compound");
231        };
232        assert!(!compound.contains("filtered_messages"));
233        assert_eq!(parse(nbt), Some(value));
234    }
235
236    #[test]
237    fn round_trips_with_differing_filtered_messages() {
238        let value = SignText::new(
239            empty_messages(),
240            Some(std::array::from_fn(|_| TextComponent::plain("filtered"))),
241            DyeColor::Red,
242            true,
243        );
244
245        let nbt = value.clone().to_nbt_tag();
246        let NbtTag::Compound(compound) = &nbt else {
247            panic!("sign text should encode as a compound");
248        };
249        assert!(compound.contains("filtered_messages"));
250        assert_eq!(parse(nbt), Some(value.clone()));
251
252        let mut network = Vec::new();
253        value.write(&mut network).expect("sign text should encode");
254        assert_eq!(
255            SignText::read(&mut Cursor::new(network.as_slice())).expect("sign text should decode"),
256            value
257        );
258    }
259
260    #[test]
261    fn persistent_codec_defaults_color_and_glow_when_absent() {
262        let mut compound = simdnbt::owned::NbtCompound::new();
263        compound.insert(
264            "messages",
265            simdnbt::owned::NbtList::String(vec!["".into(), "".into(), "".into(), "".into()]),
266        );
267        let parsed = parse(NbtTag::Compound(compound)).expect("minimal sign text should decode");
268        assert_eq!(parsed.color, DyeColor::Black);
269        assert!(!parsed.has_glowing_text);
270    }
271}