#![doc = include_str!("../docs/pretty.txt")]
#![doc = include_str!("../docs/material.txt")]
use std::borrow::Cow;
use std::marker::PhantomData;
use bevy::asset::weak_handle;
use bevy::platform::collections::HashMap;
use bevy::prelude::*;
use bevy::sprite::{Material2d, Material2dPlugin};
use crate::PrettyText;
use crate::glyph::{GlyphSystems, SpanAtlasImage};
pub(super) fn plugin(app: &mut App) {
sealed::plugin(app);
app.init_resource::<DynMaterialRegistry>()
.register_type::<ErasedPrettyTextMaterial>();
}
pub const DEFAULT_GLYPH_SHADER_HANDLE: Handle<Shader> =
weak_handle!("35d4f25c-eb2b-4f26-872f-ef666a76554e");
#[derive(Debug, Default, Clone, Component, Reflect)]
#[require(PrettyText, sealed::Material::new::<Self>())]
pub struct PrettyTextMaterial<M: TextMaterial2d>(pub Handle<M>);
pub trait TextMaterial2d: Material2d {
fn set_atlas(&mut self, atlas: Handle<Image>);
}
pub trait PrettyTextMaterialAppExt {
fn register_pretty_material<T>(&mut self, tag: &'static str) -> &mut Self
where
T: Default + TextMaterial2d + DynamicTextMaterial,
T::Data: PartialEq + Eq + std::hash::Hash + Clone;
}
impl PrettyTextMaterialAppExt for App {
fn register_pretty_material<T>(&mut self, tag: &'static str) -> &mut Self
where
T: Default + TextMaterial2d + DynamicTextMaterial,
T::Data: PartialEq + Eq + std::hash::Hash + Clone,
{
self.register_type::<PrettyTextMaterial<T>>();
self.add_plugins(PrettyTextMaterialPlugin::<T>::default())
.add_systems(PreStartup, sealed::register_dyn_material::<T>(tag))
}
}
pub struct PrettyTextMaterialPlugin<T>(PhantomData<T>);
impl<T> Default for PrettyTextMaterialPlugin<T> {
fn default() -> Self {
PrettyTextMaterialPlugin(PhantomData)
}
}
impl<T> std::fmt::Debug for PrettyTextMaterialPlugin<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("PrettyTextMaterialPlugin")
.field(&std::any::type_name::<T>())
.finish()
}
}
impl<T> Plugin for PrettyTextMaterialPlugin<T>
where
T: TextMaterial2d,
T::Data: PartialEq + Eq + std::hash::Hash + Clone,
{
fn build(&self, app: &mut App) {
app.add_plugins(Material2dPlugin::<T>::default())
.add_systems(
PostUpdate,
(
sealed::apply_material::<T>,
set_material_atlas::<T>,
ApplyDeferred,
)
.before(bevy::sprite::check_entities_needing_specialization::<T>)
.after(sealed::default_material)
.in_set(GlyphSystems::PropagateMaterial),
);
}
}
pub trait DynamicTextMaterial: Send + Sync + 'static {
fn insert_from_args(
&self,
args: &[Cow<'static, str>],
entity: &mut EntityCommands,
server: &AssetServer,
) -> Result<()>;
}
#[derive(Debug, Default, Clone, Component, Reflect)]
pub struct ErasedPrettyTextMaterial {
pub tag: Cow<'static, str>,
pub args: Vec<Cow<'static, str>>,
}
#[derive(Default, Resource)]
pub struct DynMaterialRegistry(HashMap<&'static str, Box<dyn DynamicTextMaterial>>);
impl std::fmt::Debug for DynMaterialRegistry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("DynMaterialRegistry")
.field(&self.0.keys())
.finish()
}
}
impl DynMaterialRegistry {
#[inline]
pub fn register(&mut self, tag: &'static str, material: impl DynamicTextMaterial) {
if self.0.get(tag).is_some() {
error!("material `{tag}` is already registered");
}
self.0.insert(tag, Box::new(material));
}
#[inline]
pub fn unregister(&mut self, tag: &'static str) {
self.0.remove(tag);
}
#[inline]
pub fn get(&self, tag: &str) -> Option<&dyn DynamicTextMaterial> {
self.0.get(tag).map(|mat| mat.as_ref())
}
}
pub fn set_material_atlas<T: TextMaterial2d>(
text: Query<
(&PrettyTextMaterial<T>, &SpanAtlasImage),
Or<(
Added<SpanAtlasImage>,
Added<PrettyTextMaterial<T>>,
Changed<SpanAtlasImage>,
Changed<PrettyTextMaterial<T>>,
)>,
>,
mut materials: ResMut<Assets<T>>,
) {
for (material, atlas) in text.iter() {
if let Some(material) = materials.get_mut(&material.0) {
material.set_atlas(atlas.0.clone());
}
}
}
mod sealed {
use bevy::prelude::*;
use bevy::render::render_resource::{AsBindGroup, ShaderRef};
use bevy::sprite::{AlphaMode2d, Material2d};
use crate::PrettyText;
use crate::glyph::{GlyphOf, GlyphSpanEntity, GlyphSystems};
use super::{
DEFAULT_GLYPH_SHADER_HANDLE, DynMaterialRegistry, DynamicTextMaterial,
ErasedPrettyTextMaterial, PrettyTextMaterial, PrettyTextMaterialPlugin, TextMaterial2d,
};
pub(super) fn plugin(app: &mut App) {
app.add_plugins(PrettyTextMaterialPlugin::<DefaultGlyphMaterial>::default())
.add_systems(
PostUpdate,
default_material.in_set(GlyphSystems::PropagateMaterial),
)
.add_observer(insert_erased_materials)
.register_type::<Material>()
.register_type::<DefaultGlyphMaterial>();
}
#[derive(Debug, Clone, Component, Reflect)]
pub(super) struct Material(&'static str);
impl Material {
pub fn new<T: 'static>() -> Self {
Self(std::any::type_name::<T>())
}
}
pub(super) fn default_material(
mut commands: Commands,
unmaterialized_text: Query<
(Entity, Option<&Text2d>, Option<&TextSpan>),
(
With<PrettyText>,
Without<Material>,
Or<(Added<Text2d>, Added<TextSpan>, Added<PrettyText>)>,
),
>,
mut materials: ResMut<Assets<DefaultGlyphMaterial>>,
) {
for (entity, text2d, span) in unmaterialized_text.iter() {
if text2d.is_some_and(|text| !text.0.is_empty())
|| span.is_some_and(|text| !text.0.is_empty())
{
commands.entity(entity).insert(PrettyTextMaterial(
materials.add(DefaultGlyphMaterial::default()),
));
}
}
}
pub(super) fn apply_material<T: TextMaterial2d>(
mut commands: Commands,
glyphs: Query<
(Entity, &GlyphSpanEntity),
(
With<GlyphOf>,
Without<Material>,
Without<PrettyTextMaterial<T>>,
),
>,
spans: Query<&PrettyTextMaterial<T>>,
) {
for (entity, span_entity) in glyphs.iter() {
if let Ok(material) = spans.get(span_entity.0) {
commands
.entity(entity)
.insert(MeshMaterial2d(material.0.clone()));
}
}
}
pub(super) fn register_dyn_material<T: Default + DynamicTextMaterial>(
tag: &'static str,
) -> impl Fn(ResMut<DynMaterialRegistry>) {
move |mut registry| {
registry.register(tag, T::default());
}
}
fn insert_erased_materials(
trigger: Trigger<OnAdd, ErasedPrettyTextMaterial>,
mut commands: Commands,
server: Res<AssetServer>,
materials: Query<&ErasedPrettyTextMaterial>,
registry: Res<DynMaterialRegistry>,
) -> Result {
let material = materials.get(trigger.target())?;
let handler = registry.0.get(material.tag.as_ref()).ok_or_else(|| {
format!(
"failed to insert text material: `{}` is not registered",
material.tag
)
})?;
let mut commands = commands.entity(trigger.target());
handler.insert_from_args(material.args.as_ref(), &mut commands, &server)?;
commands.remove::<ErasedPrettyTextMaterial>();
Ok(())
}
#[derive(Default, Clone, Asset, AsBindGroup, Reflect)]
pub(super) struct DefaultGlyphMaterial {
#[texture(0)]
#[sampler(1)]
pub atlas: Handle<Image>,
}
impl Material2d for DefaultGlyphMaterial {
fn vertex_shader() -> ShaderRef {
ShaderRef::Handle(DEFAULT_GLYPH_SHADER_HANDLE)
}
fn fragment_shader() -> ShaderRef {
ShaderRef::Handle(DEFAULT_GLYPH_SHADER_HANDLE)
}
fn alpha_mode(&self) -> AlphaMode2d {
AlphaMode2d::Blend
}
}
impl TextMaterial2d for DefaultGlyphMaterial {
fn set_atlas(&mut self, atlas: Handle<Image>) {
self.atlas = atlas;
}
}
#[cfg(feature = "proc-macro")]
impl quote::ToTokens for ErasedPrettyTextMaterial {
fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) {
use quote::TokenStreamExt;
let tag = &self.tag;
let args = &self.args;
tokens.append_all(quote::quote! {
bevy_pretty_text::material::erased::ErasedPrettyTextMaterial::Tag {
tag: #tag.into(),
args: vec![#(#args.into(),)*]
}
});
}
}
}
#[cfg(test)]
mod test {
use bevy::prelude::*;
use bevy::render::render_resource::AsBindGroup;
use bevy::sprite::Material2d;
use crate::dynamic_effects::PrettyTextEffect;
use crate::material::{PrettyTextMaterial, PrettyTextMaterialAppExt};
use crate::parser::{Modifier, Modifiers};
use crate::test::{prepare_app_with, run, run_tests};
use super::{DynamicTextMaterial, TextMaterial2d};
#[derive(Default, Clone, TypePath, AsBindGroup, Asset)]
struct Material {
atlas: Handle<Image>,
}
impl Material2d for Material {}
impl TextMaterial2d for Material {
fn set_atlas(&mut self, atlas: Handle<Image>) {
self.atlas = atlas;
}
}
impl DynamicTextMaterial for Material {
fn insert_from_args(
&self,
args: &[std::borrow::Cow<'static, str>],
entity: &mut EntityCommands,
server: &AssetServer,
) -> Result<()> {
assert_eq!(args.len(), 2);
entity.insert(PrettyTextMaterial(server.add(Material::default())));
Ok(())
}
}
#[test]
fn insert_material() {
run_tests(
|| {
let mut app = prepare_app_with(|app| {
app.register_pretty_material::<Material>("material");
});
app.world_mut().run_schedule(PreStartup);
app.world_mut().flush();
app
},
|app, entity, str| {
app.world_mut()
.entity_mut(entity)
.insert(Modifiers(vec![Modifier::Effect(PrettyTextEffect {
tag: "material".into(),
args: vec!["1".into(), "2".into()],
})]));
let has_spans = app
.world()
.entity(entity)
.get_components::<&Children>()
.is_some();
app.world_mut().run_schedule(PostUpdate);
app.world_mut().flush();
run(
app,
move |effect: Query<&PrettyTextMaterial<Material>>,
glyphs: Query<&MeshMaterial2d<Material>>| {
assert!(
effect.single().is_ok(),
"expected 1, got {}",
effect.iter().len()
);
if !has_spans {
assert_eq!(
glyphs.iter().count(),
str.chars().count(),
"expected {}, got {}",
str.chars().count(),
glyphs.iter().len()
);
} else {
assert_eq!(
glyphs.iter().count(),
0,
"expected 0, got {}",
glyphs.iter().len(),
);
}
},
);
app.world_mut().entity_mut(entity).despawn();
run(
app,
|effect: Query<&PrettyTextMaterial<Material>>,
glyphs: Query<&MeshMaterial2d<Material>>| {
assert!(effect.is_empty(), "expected 0, got {}", effect.iter().len());
assert!(glyphs.is_empty(), "expected 0, got {}", glyphs.iter().len());
},
);
},
);
}
}