From 4db0780ec72795d5e9ee634dff6f7916abbb6aa5 Mon Sep 17 00:00:00 2001 From: Melther Date: Sat, 4 Jul 2026 01:34:21 +0200 Subject: [PATCH] Custom content start, improved resolution Added `ResolutionHelper` to provide global data, like translations and custom content. Added `TranslationToken` to allow precompilation of translation strings. --- examples/main.rs | 159 ++++++++++++++++++++++++++++++++------------- examples/serde.rs | 4 +- examples/snbt.rs | 32 +++++++++ src/build.rs | 65 +++++++++++++++++- src/content.rs | 4 +- src/custom.rs | 22 +++---- src/fmt.rs | 70 ++++++++------------ src/lib.rs | 12 ++-- src/parse/mod.rs | 15 +++-- src/parse/nbt.rs | 7 +- src/resolving.rs | 118 +++++++++++++++------------------ src/translation.rs | 64 ++++++++++++++---- 12 files changed, 376 insertions(+), 196 deletions(-) diff --git a/examples/main.rs b/examples/main.rs index e060816..a482c28 100644 --- a/examples/main.rs +++ b/examples/main.rs @@ -1,5 +1,7 @@ +use std::{borrow::Cow, collections::HashMap, sync::Arc}; + #[cfg(feature = "custom")] -use chrono::Utc; +use chrono::Local; #[cfg(feature = "serde")] use serde::Serialize; #[cfg(feature = "nbt")] @@ -8,7 +10,7 @@ use simdnbt::{ owned::{BaseNbt, Nbt, NbtCompound, NbtTag}, }; #[cfg(feature = "custom")] -use text_components::custom::{CustomContent, CustomData, CustomRegistry, Payload}; +use text_components::custom::{CustomContent, CustomContentExt, CustomData, Payload}; #[cfg(feature = "nbt")] use text_components::nbt::{NbtBuilder, ToSNBT}; use text_components::{ @@ -17,27 +19,98 @@ use text_components::{ fmt::set_display_resolutor, format::Color, interactivity::{ClickEvent, HoverEvent}, - resolving::TextResolutor, - translation::{TranslatedMessage, Translation}, + resolving::{ResolutionHelper, TextResolutor, set_resolution_helper}, + translation::{TranslatedContent, Translation, TranslationToken}, }; use uuid::Uuid; -struct EmptyResolutor; -impl<'a> TextResolutor<'a> for EmptyResolutor { - fn translate(&self, key: &str) -> Option { - match key { - "content" => Some(String::from( - "This is a test TextComponent!\n Color: %s\n Bold: %s\n Italic: %s\n Underline: %s\n Strikethrough: %s\n Obfuscated: %s\n Shadow Color: %s\n Translation: %s\n Link: %s\n(All the green text is translated with arguments checked at compile time!)", - )), - "translated" => Some(String::from( +struct GlobalHelper { + pub translations: HashMap<&'static str, &'static [TranslationToken<'static>]>, +} +impl GlobalHelper { + fn new() -> Self { + let mut this = Self { + translations: HashMap::new(), + }; + this.register( + "en", + "content", + &[ + TranslationToken::Text(Cow::Borrowed("This is a test TextComponent!\n Color: ")), + TranslationToken::Arg(1), + TranslationToken::Text(Cow::Borrowed("\n Bold: ")), + TranslationToken::Arg(2), + TranslationToken::Text(Cow::Borrowed("\n Italic: ")), + TranslationToken::Arg(3), + TranslationToken::Text(Cow::Borrowed("\n Underline: ")), + TranslationToken::Arg(4), + TranslationToken::Text(Cow::Borrowed("\n Strikethrough: ")), + TranslationToken::Arg(5), + TranslationToken::Text(Cow::Borrowed("\n Obfuscated: ")), + TranslationToken::Arg(6), + TranslationToken::Text(Cow::Borrowed("\n Shadow Color: ")), + TranslationToken::Arg(7), + TranslationToken::Text(Cow::Borrowed("\n Translation: ")), + TranslationToken::Arg(8), + TranslationToken::Text(Cow::Borrowed("\n Link: ")), + TranslationToken::Arg(9), + TranslationToken::Text(Cow::Borrowed( + "\n(All the green text is translated with arguments checked at compile time!)", + )), + ], + ); + this.register( + "en", + "translated", + &[TranslationToken::Text(Cow::Borrowed( "This text is Translated! (Without compile time check!)", - )), - "resoluble" => Some(String::from( - "\n\nResolubles:\n Object: %s\n Scoreboard: %s\n Entity: %s\n Nbt: %s", - )), - _ => None, + ))], + ); + this.register( + "en", + "resoluble", + &[ + TranslationToken::Text(Cow::Borrowed("\n\nResolubles:\n Object: ")), + TranslationToken::Arg(1), + TranslationToken::Text(Cow::Borrowed("\n Scoreboard: ")), + TranslationToken::Arg(2), + TranslationToken::Text(Cow::Borrowed("\n Entity: ")), + TranslationToken::Arg(3), + TranslationToken::Text(Cow::Borrowed("\n Nbt: ")), + TranslationToken::Arg(4), + ], + ); + this + } + + fn register( + &mut self, + _locale: &'static str, + key: &'static str, + translation: &'static [TranslationToken<'static>], + ) { + self.translations.insert(key, translation); + } +} +impl<'a> ResolutionHelper<'a> for GlobalHelper { + #[cfg(feature = "custom")] + fn resolve_custom( + &self, + resolutor: &dyn TextResolutor<'a>, + data: &CustomData, + ) -> Option> { + if data.id == "time" { + return Some(TimeContent.resolve(resolutor, (), Payload::Empty)); } + None } + fn translate(&self, _locale: &str, key: &str) -> Option<&[TranslationToken<'a>]> { + self.translations.get(key).copied() + } +} + +struct EmptyResolutor; +impl<'a> TextResolutor<'a> for EmptyResolutor { fn resolve_content(&self, resolvable: &Resolvable) -> RawTextComponent<'a> { match resolvable { Resolvable::Scoreboard { .. } => RawTextComponent::plain("5"), @@ -69,25 +142,6 @@ impl<'a> TextResolutor<'a> for EmptyResolutor { } } } - #[cfg(feature = "custom")] - fn resolve_custom(&self, data: &CustomData) -> Option> { - if data.id == "time" { - return Some(TimeContent.resolve((), Payload::Empty)); - } - None - } -} -#[cfg(feature = "custom")] -impl<'a> CustomRegistry<'a> for EmptyResolutor { - type Data = (); - - fn register_content>(&mut self, _id: &'a str, _content: T) { - todo!() - } - - fn get_content(&self, _id: String) -> Box> { - Box::new(TimeContent) - } } const CONTENT: Translation<9> = Translation("content"); @@ -96,23 +150,40 @@ const RESOLUBLE: Translation<4> = Translation("resoluble"); #[cfg(feature = "custom")] struct TimeContent; #[cfg(feature = "custom")] -impl<'a> CustomContent<'a> for TimeContent { - type Reg = EmptyResolutor; - +impl<'a> CustomContentExt<'a> for TimeContent { fn as_data(&self) -> CustomData<'a> { CustomData { id: std::borrow::Cow::Borrowed("time"), payload: Payload::Empty, } } - - fn resolve(&self, _data: (), _payload: Payload) -> RawTextComponent<'a> { - RawTextComponent::plain(Utc::now().format("%H:%M").to_string()) +} +#[cfg(feature = "custom")] +impl<'a> CustomContent<'a, ()> for TimeContent { + fn resolve( + &self, + _resolutor: &dyn TextResolutor<'a>, + _context: (), + _payload: Payload, + ) -> RawTextComponent<'a> { + RawTextComponent::plain(Local::now().format("%H:%M").to_string()) + } +} +#[cfg(feature = "custom")] +impl<'a> CustomContent<'a, i8> for TimeContent { + fn resolve( + &self, + _resolutor: &dyn TextResolutor<'a>, + _context: i8, + _payload: Payload, + ) -> RawTextComponent<'a> { + RawTextComponent::plain(Local::now().format("%H:%M").to_string()) } } fn main() { set_display_resolutor(&EmptyResolutor); + set_resolution_helper(Arc::new(GlobalHelper::new())); let resolubles = RESOLUBLE .message([ ObjectPlayer::name("MrMelther").reset(), @@ -133,7 +204,7 @@ fn main() { "This text is ShadowcoloRED!" .reset() .shadow_color(255, 128, 0, 0), - TranslatedMessage::new("translated", None).reset(), + TranslatedContent::new("translated", None).reset(), "This text contains a link!" .click_event(ClickEvent::open_url( "https://github.com/Steel-Foundation/TextComponents", @@ -148,7 +219,7 @@ fn main() { component.add_child(resolubles.add_children(vec!["\n Custom: ".into(), TimeContent.reset()])) } _ => { - component + component.add_child(resolubles) } }; diff --git a/examples/serde.rs b/examples/serde.rs index b35202b..9baf11f 100644 --- a/examples/serde.rs +++ b/examples/serde.rs @@ -1,7 +1,7 @@ -use text_components::{Modifier, RawTextComponent, format::Color, translation::TranslatedMessage}; +use text_components::{Modifier, RawTextComponent, format::Color, translation::TranslatedContent}; fn main() { - let component: RawTextComponent = TranslatedMessage::new("key", None) + let component: RawTextComponent = TranslatedContent::new("key", None) .color(Color::Blue) .bold(true); println!("{}", serde_json::to_string_pretty(&component).unwrap()); diff --git a/examples/snbt.rs b/examples/snbt.rs index bcbee73..0173a66 100644 --- a/examples/snbt.rs +++ b/examples/snbt.rs @@ -1,7 +1,38 @@ use text_components::RawTextComponent; +#[cfg(feature = "custom")] +mod custom { + use text_components::{ + RawTextComponent, + custom::CustomData, + resolving::{ResolutionHelper, TextResolutor}, + translation::TranslationToken, + }; + + pub struct GlobalHelper; + impl<'a> ResolutionHelper<'a> for GlobalHelper { + fn resolve_custom( + &self, + _resolutor: &dyn TextResolutor<'a>, + data: &CustomData<'a>, + ) -> Option> { + match data.id.as_ref() { + "hello" => Some(RawTextComponent::const_plain("Hello World!")), + _ => None, + } + } + + fn translate(&self, _locale: &str, _key: &str) -> Option<&[TranslationToken<'a>]> { + None + } + } +} + fn main() { use std::io::{Write, stdin, stdout}; + #[cfg(feature = "custom")] + text_components::resolving::set_resolution_helper(std::sync::Arc::new(custom::GlobalHelper)); + let mut s = String::new(); print!("/tellraw @s "); let _ = stdout().flush(); @@ -25,4 +56,5 @@ fn main() { // ["\"Howdy!\"", { text:"\nThis is a text component!\n", color:'blue', "bold":1b, italic:true }, {text:"Texto" , type: "translatable", fallback:"lol\n", translate:"lmao"}, {sprite:"items/iron_sword"}, "\n", {object:"player", player:{name:"MrMelther"}, hover_event:{action:"show_text",value:{text:"Send msg to MrMelther"}}, click_event:{action:"suggest_command", command:"/msg MrMelther "} }, {object:"player", player:{properties:[{name:"textures", value:"[Put your base64 texture here!]"}]}}] // {text:"Test",extra:[" lmao"]} // "Workflow test!" + // {custom:{id:"hello"}, color:"green"} } diff --git a/src/build.rs b/src/build.rs index 0269e89..6bc82a7 100644 --- a/src/build.rs +++ b/src/build.rs @@ -4,6 +4,8 @@ use quote::quote; use serde_json::Value; use std::fs; +use crate::translation::TranslationToken; + /// Count the number of parameters in a translation string fn count_parameters(text: &str) -> usize { let sequential = text.matches("%s").count(); @@ -16,6 +18,40 @@ fn count_parameters(text: &str) -> usize { sequential.max(positional) } +fn process_tokens(text: &str) -> (TokenStream, i32) { + let mut positions = vec![(0, 0, 0), (text.len(), 0, 0)]; + for i in 1..=8 { + for (pos, _) in text.match_indices(&format!("%{i}$s")) { + positions.push((pos, i, 4usize)); + } + } + for (counter, (pos, _)) in (1..).zip(text.match_indices("%s")) { + positions.push((pos, counter, 2usize)); + } + positions.sort_by_key(|(pos, _, _)| *pos); + let mut positions = positions.into_iter().peekable(); + let mut stream = TokenStream::new(); + let mut amount = 0; + while let Some((pos, _, size)) = positions.next() { + let Some(next) = positions.peek() else { + break; + }; + let text = text[pos + size..next.0].to_string(); + let arg = next.1; + stream.extend(quote! { + TranslationToken::Text(Cow::borrow(#text)), + }); + amount += 1; + if arg > 0 { + stream.extend(quote! { + TranslationToken::Arg(#arg), + }); + amount += 1; + } + } + (quote! {[#stream]}, amount) +} + pub fn build_translations(path: &str) -> TokenStream { println!("cargo:rerun-if-changed={path}"); @@ -30,7 +66,11 @@ pub fn build_translations(path: &str) -> TokenStream { // Add imports stream.extend(quote! { #![allow(dead_code)] - use text_components::translation::Translation; + use text_components::{ + translation::{Translation, TranslationToken}, + build::TranslationsRegistry + }; + use std::borrow::Cow; }); // Generate constants for each translation @@ -39,6 +79,7 @@ pub fn build_translations(path: &str) -> TokenStream { // Track used constant names to handle collisions let mut used_names = rustc_hash::FxHashMap::default(); + let mut register_stream = TokenStream::new(); for (key, value) in translations_vec { let Some(text) = value.as_str() else { @@ -67,12 +108,34 @@ pub fn build_translations(path: &str) -> TokenStream { } let const_name = Ident::new(&const_name_str, Span::call_site()); + let (translation_tokens, tokens_amount) = process_tokens(text); + let token_const_name = Ident::new(&format!("{const_name_str}_TOKEN"), Span::call_site()); stream.extend(quote! { #[doc = #text] pub static #const_name: Translation<#param_count> = Translation(#key); + static #token_const_name: [TranslationToken<'static>; #tokens_amount] = #translation_tokens; + }); + + register_stream.extend(quote! { + registry.register("en", #key, &#token_const_name); }); } + stream.extend(quote! { + fn register_translations(registry: &mut R) { + #register_stream + } + }); + stream } + +pub trait TranslationsRegistry { + fn register( + &mut self, + locale: &'static str, + key: &'static str, + translation: &'static [TranslationToken<'static>], + ); +} diff --git a/src/content.rs b/src/content.rs index d73204a..5423dc2 100644 --- a/src/content.rs +++ b/src/content.rs @@ -1,7 +1,7 @@ #[cfg(feature = "custom")] use crate::custom::CustomData; use crate::{ - RawTextComponent, format::Format, interactivity::Interactivity, translation::TranslatedMessage, + RawTextComponent, format::Format, interactivity::Interactivity, translation::TranslatedContent, }; use std::borrow::Cow; @@ -15,7 +15,7 @@ pub enum Content<'a> { Text { text: Cow<'a, str>, }, - Translate(TranslatedMessage<'a>), + Translate(TranslatedContent<'a>), Keybind { keybind: Cow<'a, str>, }, diff --git a/src/custom.rs b/src/custom.rs index 5fd448c..aa0160a 100644 --- a/src/custom.rs +++ b/src/custom.rs @@ -1,4 +1,4 @@ -use crate::RawTextComponent; +use crate::{RawTextComponent, resolving::TextResolutor}; use std::borrow::Cow; #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -31,18 +31,15 @@ impl Payload { } } -pub trait CustomRegistry<'a> { - type Data; - fn register_content>(&mut self, id: &'a str, content: T); - fn get_content(&self, id: String) -> Box>; +pub trait CustomContentExt<'a> { + fn as_data(&self) -> CustomData<'a>; } -pub trait CustomContent<'a> { - type Reg: CustomRegistry<'a>; - fn as_data(&self) -> CustomData<'a>; +pub trait CustomContent<'a, Ctx>: CustomContentExt<'a> { fn resolve( &self, - data: >::Data, + resolutor: &dyn TextResolutor<'a>, + context: Ctx, payload: Payload, ) -> RawTextComponent<'a>; } @@ -55,8 +52,11 @@ impl<'a> From> for RawTextComponent<'a> { } } } -impl<'a, T: CustomContent<'a> + 'a> From for RawTextComponent<'a> { +impl<'a, T: CustomContentExt<'a>> From for RawTextComponent<'a> { fn from(value: T) -> Self { - RawTextComponent::custom(value) + RawTextComponent { + content: crate::content::Content::Custom(value.as_data()), + ..Default::default() + } } } diff --git a/src/fmt.rs b/src/fmt.rs index c966dcd..6da8fb2 100644 --- a/src/fmt.rs +++ b/src/fmt.rs @@ -3,7 +3,8 @@ use crate::{ content::{Content, Object}, format::{Color, Format}, interactivity::{ClickEvent, Interactivity}, - resolving::{BuildTarget, NoResolutor, TextResolutor}, + resolving::{BuildTarget, NoResolutor, RESOLUTION_HELPER, TextResolutor}, + translation::TranslationTokenArray, }; use rand::random_range; use std::{ @@ -69,38 +70,23 @@ impl TextBuilder { ) -> String { match &component.content { Content::Text { text } => text.to_string(), - Content::Translate(message) => { - let translated = match resolutor.translate(&message.key) { - Some(t) => t, - None => match &message.fallback { - Some(f) => return f.to_string(), - None => return format!("[Translation: {}]", message.key), - }, - }; - let parts = resolutor.split_translation(translated); - let mut built_parts = vec![]; - for (part, pos) in parts { - let component_part = RawTextComponent { - content: part.into(), - format: component.format.clone(), - ..RawTextComponent::new() - }; - built_parts.push(target.build_component(resolutor, &component_part)); - if pos != 0 - && let Some(args) = &message.args - && pos <= args.len() - && let Some(arg) = args.get(pos - 1) - { - let arg_part = RawTextComponent { - content: arg.content.clone(), - children: arg.children.clone(), - format: arg.format.mix(&component.format), - interactions: arg.interactions.clone(), - }; - built_parts.push(target.build_component(resolutor, &arg_part)); + Content::Translate(content) => { + match RESOLUTION_HELPER + .get() + .and_then(|h| h.translate(resolutor.locale(), &content.key)) + { + Some(t) => { + let mut translated_component = t.component(&content.args); + translated_component.format = + translated_component.format.mix(&component.format); + translated_component.interactions = component.interactions.clone(); + target.build_component(resolutor, &translated_component) } + None => match &content.fallback { + Some(f) => f.to_string(), + None => format!("[Translation: {}]", content.key), + }, } - built_parts.concat() } Content::Keybind { keybind } => format!("[Keybind: {}]", keybind), Content::Object(Object::Atlas { sprite, .. }) => format!("[Object: {}]", sprite), @@ -156,10 +142,8 @@ impl<'a> BuildTarget<'a> for PrettyTextBuilder { let mut final_text = TextBuilder::stringify_content(self, resolutor, component); if let Content::Translate(_) = component.content { - return format!( - "{}{}", - final_text, - component + return final_text + + &component .children .iter() .map(|child| { @@ -172,8 +156,15 @@ impl<'a> BuildTarget<'a> for PrettyTextBuilder { self.build_component(resolutor, &child) }) .collect::>() - .concat() - ); + .concat(); + } + + if let Some(ClickEvent::OpenUrl { url }) = &component.interactions.click { + final_text = if supports_hyperlinks() { + format!("{URL_START}{}{URL_SEPARATOR}{}{URL_END}", url, final_text) + } else { + format!("{final_text} ({url})") + }; } if let Some(true) = component.format.obfuscated { @@ -213,11 +204,6 @@ impl<'a> BuildTarget<'a> for PrettyTextBuilder { ), ); } - if supports_hyperlinks() - && let Some(ClickEvent::OpenUrl { url }) = &component.interactions.click - { - final_text = format!("{URL_START}{}{URL_SEPARATOR}{}{URL_END}", url, final_text); - } final_text.push_str(RESET); format!( diff --git a/src/lib.rs b/src/lib.rs index e7978d8..9ffdb24 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,10 +1,10 @@ #[cfg(feature = "custom")] -use crate::custom::CustomContent; +use crate::custom::CustomContentExt; use crate::{ content::{Content, NbtSource, Object, ObjectPlayer, Resolvable}, format::{Color, Format}, interactivity::{ClickEvent, HoverEvent, Interactivity}, - translation::TranslatedMessage, + translation::TranslatedContent, }; use std::borrow::Cow; @@ -156,7 +156,7 @@ impl<'a> RawTextComponent<'a> { } } - /// Creates a [TextComponent] of a [TranslatedMessage], it's recommended using a compiled + /// Creates a [TextComponent] of a [TranslatedContent], it's recommended using a compiled /// [Translation](crate::translation::Translation) which forces you to give it the right amount of arguments. /// ## Examples /// #### For a translation without arguments: @@ -177,7 +177,7 @@ impl<'a> RawTextComponent<'a> { /// // Results in "The Rust compiler was killed by you using magic". /// TextComponent::translated(DEATH_ATTACK_INDIRECT_MAGIC.message(["The Rust compiler", "you"])); /// ``` - pub const fn translated(message: TranslatedMessage<'a>) -> Self { + pub const fn translated(message: TranslatedContent<'a>) -> Self { RawTextComponent { content: Content::Translate(message), children: vec![], @@ -305,7 +305,7 @@ impl<'a> RawTextComponent<'a> { } #[cfg(feature = "custom")] - pub fn custom(content: impl CustomContent<'a> + 'a) -> RawTextComponent<'a> { + pub fn custom(content: impl CustomContentExt<'a> + 'a) -> RawTextComponent<'a> { RawTextComponent { content: Content::Custom(content.as_data()), children: vec![], @@ -336,7 +336,7 @@ pub trait Modifier<'a> { type Output; /// Adds a child at the end of a text component fn add_child>>(self, child: T) -> Self::Output; - /// Appends a [vec] of [Into]<[TextComponent]> as children of this component + /// Appends a [vec] of [Into]<[RawTextComponent]> as children of this component fn add_children>>(self, children: Vec) -> Self::Output; /// Sets the Shift+Click chat insertion string fn insertion>>(self, insertion: T) -> Self::Output; diff --git a/src/parse/mod.rs b/src/parse/mod.rs index f9d4191..ce03c6d 100644 --- a/src/parse/mod.rs +++ b/src/parse/mod.rs @@ -5,7 +5,7 @@ use crate::{ content::{Content, NbtSource, Object, ObjectPlayer, PlayerProperties, Resolvable}, format::{Color, Format}, interactivity::{ClickEvent, HoverEvent, Interactivity}, - translation::TranslatedMessage, + translation::TranslatedContent, }; use std::{borrow::Cow, error::Error, fmt::Display, iter::Peekable, ops::AddAssign, str::Chars}; use uuid::Uuid; @@ -259,7 +259,7 @@ fn match_content( if let Some(Content::Translate(msg)) = &mut compound.contents[1] { msg.key = Cow::Owned(parse_string(first, chars)?); } else { - compound.contents[1] = Some(Content::Translate(TranslatedMessage { + compound.contents[1] = Some(Content::Translate(TranslatedContent { key: Cow::Owned(parse_string(first, chars)?), fallback: None, args: None, @@ -274,7 +274,7 @@ fn match_content( if let Some(Content::Translate(msg)) = &mut compound.contents[1] { msg.fallback = Some(Cow::Owned(parse_string(first, chars)?)); } else { - compound.contents[1] = Some(Content::Translate(TranslatedMessage { + compound.contents[1] = Some(Content::Translate(TranslatedContent { key: Cow::Borrowed(""), fallback: Some(Cow::Owned(parse_string(first, chars)?)), args: None, @@ -289,7 +289,7 @@ fn match_content( if let Some(Content::Translate(msg)) = &mut compound.contents[1] { msg.args = Some(parse_vec(chars)?.into_boxed_slice()); } else { - compound.contents[1] = Some(Content::Translate(TranslatedMessage { + compound.contents[1] = Some(Content::Translate(TranslatedContent { key: Cow::Borrowed(""), fallback: None, args: Some(parse_vec(chars)?.into_boxed_slice()), @@ -505,10 +505,11 @@ fn match_content( } #[cfg(feature = "custom")] "custom" => { - if first == '{' { - compound.contents[8] = Some(Content::Custom(parse_custom(chars)?)); + if first != '{' { + return Err(SnbtError::WrongContentType(name.to_string())); } - Err(SnbtError::WrongContentType(name.to_string())) + compound.contents[8] = Some(Content::Custom(parse_custom(chars)?)); + Ok(()) } _ => { unknown.add_assign(1); diff --git a/src/parse/nbt.rs b/src/parse/nbt.rs index da05727..c58c12f 100644 --- a/src/parse/nbt.rs +++ b/src/parse/nbt.rs @@ -1,10 +1,11 @@ +#[cfg(feature = "custom")] +use crate::custom::CustomData; use crate::{ TextComponent, content::{Content, NbtSource, Object, ObjectPlayer, PlayerProperties, Resolvable}, - custom::CustomData, format::{Color, Format}, interactivity::{ClickEvent, HoverEvent, Interactivity}, - translation::TranslatedMessage, + translation::TranslatedContent, }; use simdnbt::owned::{NbtCompound, NbtList, NbtTag}; @@ -84,7 +85,7 @@ impl<'a> Content<'a> { args = Some(args_vec.into_boxed_slice()); } - return Some(Content::Translate(TranslatedMessage { + return Some(Content::Translate(TranslatedContent { key: key.to_string().into(), fallback, args, diff --git a/src/resolving.rs b/src/resolving.rs index 30aae57..62a879b 100644 --- a/src/resolving.rs +++ b/src/resolving.rs @@ -1,12 +1,39 @@ -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; #[cfg(feature = "custom")] use crate::custom::CustomData; use crate::{ RawTextComponent, content::{Content, Resolvable}, + translation::TranslationToken, }; +pub(crate) static RESOLUTION_HELPER: OnceLock ResolutionHelper<'a> + Send + Sync>> = + OnceLock::new(); + +pub fn set_resolution_helper(resolutor: Arc ResolutionHelper<'a> + Send + Sync>) { + RESOLUTION_HELPER.get_or_init(|| resolutor); +} + +/// Trait to provide global data for resolving content within a text component. +pub trait ResolutionHelper<'a> { + /// Resolves a custom data block into an optional `RawTextComponent`. + /// + /// Only available when the `custom` feature is enabled. Return `None` if + /// the custom ID is not recognized or cannot be resolved. + #[cfg(feature = "custom")] + fn resolve_custom( + &self, + resolutor: &dyn TextResolutor<'a>, + data: &CustomData<'a>, + ) -> Option>; + + /// Translates a given translation key into pre-compiled [TranslationToken]s. + /// + /// Returns `None` if the key is unknown. + fn translate(&self, locale: &str, key: &str) -> Option<&[TranslationToken<'a>]>; +} + /// Trait for resolving dynamic content within a text component. /// /// This trait provides the necessary hooks to replace placeholders (like scoreboards, @@ -16,7 +43,7 @@ use crate::{ /// live data. /// /// # Recommendation -/// Implement this on the `World` and `Player` types in your application. +/// Implement this on the `World`, `Player`, and `Entity` types / traits in your application. pub trait TextResolutor<'a> { /// Fallback resolution for any `Content` variant that is not explicitly handled. /// @@ -34,69 +61,36 @@ pub trait TextResolutor<'a> { /// or a formatted NBT tag). fn resolve_content(&self, resolvable: &Resolvable<'a>) -> RawTextComponent<'a>; - /// Resolves a custom data block into an optional `RawTextComponent`. - /// - /// Only available when the `custom` feature is enabled. Return `None` if - /// the custom ID is not recognized or cannot be resolved. - #[cfg(feature = "custom")] - fn resolve_custom(&self, data: &CustomData<'a>) -> Option>; - - /// Translates a given translation key into a human-readable string. - /// - /// Returns `None` if the key is unknown. The returned string may contain - /// parameter placeholders (`%s` or `%n$s`) that will be processed by - /// `split_translation`. - fn translate(&self, key: &str) -> Option; + /// Returns the current locale of the resolver + fn locale(&self) -> &str { + "en" + } - /// Splits a translation string into segments and parameter indices. - /// - /// This method parses placeholders like `%s` (sequential) and `%n$s` - /// (positional) and returns a vector of `(text, param_index)`. The - /// `text` part is the literal substring, and `param_index` is the - /// 1‑based argument position (or 0 for trailing text). The default - /// implementation handles up to 8 positional parameters and any number - /// of sequential ones. + /// Returns the ID of the resolving entity, if available. /// - /// You may override this if your translation format differs. - fn split_translation(&self, text: String) -> Vec<(String, usize)> { - let mut positions = vec![(0, 0, 0), (text.len(), 0, 0)]; - for i in 1..=8 { - for (pos, _) in text.match_indices(&format!("%{i}$s")) { - positions.push((pos, i, 4usize)); - } - } - for (counter, (pos, _)) in (1..).zip(text.match_indices("%s")) { - positions.push((pos, counter, 2usize)); - } - positions.sort_by_key(|(pos, _, _)| *pos); - let mut translation = vec![]; - let mut positions = positions.into_iter().peekable(); - while let Some((pos, _, size)) = positions.next() { - let Some(next) = positions.peek() else { - break; - }; - translation.push((text[pos + size..next.0].to_string(), next.1)); - } - translation + /// Useful to get data from the receiver in custom contents. + fn entity_id(&self) -> Option { + None } } impl<'a, T: TextResolutor<'a>> TextResolutor<'a> for Arc { - fn resolve_content(&self, resolvable: &Resolvable<'a>) -> RawTextComponent<'a> { - (**self).resolve_content(resolvable) + fn resolve_other(&self, content: &Content<'a>) -> RawTextComponent<'a> { + (**self).resolve_other(content) } - #[cfg(feature = "custom")] - fn resolve_custom(&self, data: &CustomData<'a>) -> Option> { - (**self).resolve_custom(data) + fn resolve_content(&self, resolvable: &Resolvable<'a>) -> RawTextComponent<'a> { + (**self).resolve_content(resolvable) } +} - fn translate(&self, key: &str) -> Option { - (**self).translate(key) +impl<'a, T: TextResolutor<'a> + ?Sized> TextResolutor<'a> for &T { + fn resolve_other(&self, content: &Content<'a>) -> RawTextComponent<'a> { + (**self).resolve_other(content) } - fn split_translation(&self, text: String) -> Vec<(String, usize)> { - (**self).split_translation(text) + fn resolve_content(&self, resolvable: &Resolvable<'a>) -> RawTextComponent<'a> { + (**self).resolve_content(resolvable) } } @@ -119,15 +113,6 @@ impl<'a> TextResolutor<'a> for NoResolutor { Resolvable::NBT { path, .. } => RawTextComponent::plain(format!("[Nbt: {path}]")), } } - - #[cfg(feature = "custom")] - fn resolve_custom(&self, data: &crate::custom::CustomData<'a>) -> Option> { - Some(RawTextComponent::plain(data.id.clone())) - } - - fn translate(&self, _key: &str) -> Option { - None - } } impl<'a> RawTextComponent<'a> { @@ -179,7 +164,7 @@ impl<'a> RawTextComponent<'a> { /// /// This replaces `Resolvable` and `Custom` content with actual components /// obtained from the `resolutor`, and also resolves arguments inside - /// `TranslatedMessage`, separators for entity/NBT resolvables, and children. + /// `TranslatedContent`, separators for entity/NBT resolvables, and children. /// Formatting and interactivity are merged appropriately. /// /// The returned component is fully static (no more `Resolvable` leaves) @@ -187,9 +172,10 @@ impl<'a> RawTextComponent<'a> { pub fn resolve + ?Sized>(&self, resolutor: &R) -> RawTextComponent<'a> { let mut component = match &self.content { #[cfg(feature = "custom")] - Content::Custom(data) => resolutor - .resolve_custom(data) - .unwrap_or(RawTextComponent::new()), + Content::Custom(data) => RESOLUTION_HELPER + .get() + .and_then(|h| h.resolve_custom(&resolutor, data)) + .unwrap_or_default(), Content::Resolvable(resolvable) => resolutor.resolve_content(resolvable), content => resolutor.resolve_other(content), }; diff --git a/src/translation.rs b/src/translation.rs index 08d735f..80b7646 100644 --- a/src/translation.rs +++ b/src/translation.rs @@ -1,4 +1,4 @@ -use crate::RawTextComponent; +use crate::{Modifier, RawTextComponent}; use std::borrow::Cow; #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -6,7 +6,7 @@ use std::borrow::Cow; #[cfg_attr(feature = "databake", databake(path = text_components::translation))] #[cfg_attr(feature = "ownable", derive(::ownable::IntoOwned, ::ownable::ToOwned))] #[cfg_attr(feature = "serde", derive(::serde::Serialize, ::serde::Deserialize))] -pub struct TranslatedMessage<'a> { +pub struct TranslatedContent<'a> { #[cfg_attr(feature = "serde", serde(rename = "translate"))] pub key: Cow<'a, str>, #[cfg_attr( @@ -20,8 +20,8 @@ pub struct TranslatedMessage<'a> { )] pub args: Option]>>, } -impl<'a> TranslatedMessage<'a> { - /// Creates a new `TranslatedMessage` without fallback. +impl<'a> TranslatedContent<'a> { + /// Creates a new `TranslatedContent` without fallback. /// ### Warning /// Using this method directly is discouraged. /// Please use a compiled [Translation] instead. @@ -44,8 +44,8 @@ impl<'a> TranslatedMessage<'a> { } } -impl<'a> From> for RawTextComponent<'a> { - fn from(value: TranslatedMessage<'a>) -> Self { +impl<'a> From> for RawTextComponent<'a> { + fn from(value: TranslatedContent<'a>) -> Self { value.component() } } @@ -53,18 +53,18 @@ impl<'a> From> for RawTextComponent<'a> { pub struct Translation<'a, const ARGS: usize>(pub &'a str); impl<'a> Translation<'a, 0> { - /// Creates a new `TranslatedMessage` with no arguments. + /// Creates a new `TranslatedContent` with no arguments. #[must_use] - pub const fn msg(&self) -> TranslatedMessage<'_> { - TranslatedMessage::new(self.0, None) + pub const fn msg(&self) -> TranslatedContent<'_> { + TranslatedContent::new(self.0, None) } } impl<'a, const ARGS: usize> Translation<'a, ARGS> { - /// Creates a new `TranslatedMessage` with the given arguments. + /// Creates a new `TranslatedContent` with the given arguments. #[must_use] - pub fn message(&self, args: [impl Into>; ARGS]) -> TranslatedMessage<'_> { - TranslatedMessage::new(self.0, Some(Box::new(args.map(Into::into)))) + pub fn message(&self, args: [impl Into>; ARGS]) -> TranslatedContent<'_> { + TranslatedContent::new(self.0, Some(Box::new(args.map(Into::into)))) } } @@ -73,3 +73,43 @@ impl<'a> From<&'a Translation<'a, 0>> for RawTextComponent<'a> { value.msg().component() } } + +/// Minimal part of a full translation +/// +/// It has 2 possible values +/// Text -> Raw text parts of the translation +/// Arg -> 1 based index of the translation arguments +pub enum TranslationToken<'a> { + Text(Cow<'a, str>), + Arg(usize), +} +impl<'a> TranslationToken<'a> { + pub fn component( + &'a self, + values: &Option]>>, + ) -> RawTextComponent<'a> { + match self { + Self::Text(text) => RawTextComponent::const_plain(text), + Self::Arg(idx) => values + .as_ref() + .map(|component| component[*idx - 1].clone()) + .unwrap_or_default(), + } + } +} + +pub trait TranslationTokenArray<'a> { + fn component(&'a self, values: &Option]>>) -> RawTextComponent<'a>; +} +impl<'a> TranslationTokenArray<'a> for [TranslationToken<'a>] { + fn component(&'a self, values: &Option]>>) -> RawTextComponent<'a> { + let mut tokens = self.iter(); + let mut component = tokens + .next() + .map_or_else(RawTextComponent::new, |c| c.component(values)); + for token in tokens { + component = component.add_child(token.component(values)); + } + component + } +}