#![doc = include_str!("../docs/pretty.txt")]
#![doc = include_str!("../docs/pretty.txt")]
#![doc = include_str!("../docs/pretty.txt")]
use std::borrow::Cow;
use bevy::prelude::*;
use crate::PrettyText;
use crate::dynamic_effects::PrettyTextEffect;
use crate::style::SpanStyle;
use crate::type_writer::hierarchy::{TypeWriterCallback, TypeWriterCommand, TypeWriterEvent};
#[derive(Debug)]
pub struct PrettyTextParser;
impl PrettyTextParser {
pub fn bundle(pretty_text: &str) -> Result<impl Bundle, String> {
Self::spans(pretty_text).map(PrettyTextSpans::into_bundle)
}
pub fn spans(pretty_text: &str) -> Result<PrettyTextSpans, String> {
sealed::parse_bundles(pretty_text).map(PrettyTextSpans)
}
}
#[derive(Debug, Clone, Component, Reflect)]
#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serialize", reflect(Serialize, Deserialize))]
pub struct PrettyTextSpans(pub Vec<TextSpanBundle>);
impl PrettyTextSpans {
pub fn into_bundle(self) -> impl Bundle {
(
PrettyText,
Text2d::default(),
Children::spawn(sealed::TextSpanSpawner::from_vec(self.0)),
)
}
}
pub(crate) fn pretty_text_spans(
trigger: Trigger<OnInsert, PrettyTextSpans>,
mut commands: Commands,
spans: Query<&PrettyTextSpans>,
) {
let spans = spans.get(trigger.target()).unwrap();
commands
.entity(trigger.target())
.insert(spans.clone().into_bundle());
}
#[derive(Debug, Clone, Reflect)]
#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serialize", reflect(Serialize, Deserialize))]
pub enum TextSpanBundle {
Span {
span: Span,
mods: Modifiers,
},
Effect(TypeWriterCommand),
Event(TypeWriterEvent),
Callback(
#[cfg_attr(feature = "serialize", serde(skip))]
#[cfg_attr(feature = "serialize", reflect(skip_serializing))]
TypeWriterCallback,
),
}
impl TextSpanBundle {
pub fn with_parent(self, entity: &mut EntityCommands) {
spawn_bundle_with_parent(self, entity);
}
}
#[derive(Debug, Clone, Reflect)]
#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serialize", reflect(Serialize, Deserialize))]
#[reflect(no_field_bounds)]
pub enum Span {
Text(Cow<'static, str>),
Bundles(Vec<TextSpanBundle>),
}
#[derive(Debug, Default, Clone, Component, Reflect)]
#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serialize", reflect(Serialize, Deserialize))]
pub struct Modifiers(pub Vec<Modifier>);
#[derive(Debug, Clone, Reflect)]
#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serialize", reflect(Serialize, Deserialize))]
pub enum Modifier {
Effect(PrettyTextEffect),
Style(SpanStyle),
}
#[cfg(feature = "proc-macro")]
impl quote::ToTokens for Modifier {
fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) {
use quote::TokenStreamExt;
tokens.append_all(match self {
Self::Effect(effect) => {
quote::quote! {
bevy_pretty_text::parser::Modifier::Effect(#effect)
}
}
Self::Style(style) => {
quote::quote! {
bevy_pretty_text::parser::Modifier::Style(#style)
}
}
});
}
}
fn spawn_bundle_with_parent(bundle: TextSpanBundle, entity: &mut EntityCommands) {
match bundle {
TextSpanBundle::Span { span, mods } => match span {
Span::Text(text) => {
entity.with_child((PrettyText, TextSpan::new(text), mods));
}
Span::Bundles(bundles) => {
let mut mods = mods.0;
for bundle in bundles.into_iter() {
spawn_bundle_with_parent_recur(bundle, entity, &mut mods);
}
}
},
TextSpanBundle::Effect(effect) => {
entity.with_child(effect);
}
TextSpanBundle::Event(tag) => {
entity.with_child(tag);
}
TextSpanBundle::Callback(callback) => {
entity.with_child(callback);
}
}
}
fn spawn_bundle_with_parent_recur(
bundle: TextSpanBundle,
entity: &mut EntityCommands,
parent_mods: &mut Vec<Modifier>,
) {
match bundle {
TextSpanBundle::Span { span, mods } => match span {
Span::Text(text) => {
let mut new_effects = mods.0;
if !parent_mods.is_empty() {
for effect in parent_mods.iter() {
new_effects.push(effect.clone());
}
}
entity.with_child((
PrettyText,
TextSpan::new(text.as_ref()),
Modifiers(new_effects),
));
}
Span::Bundles(bundles) => {
let len = mods.0.len();
parent_mods.extend(mods.0);
for bundle in bundles.into_iter() {
spawn_bundle_with_parent_recur(bundle, entity, parent_mods);
}
for _ in 0..len {
parent_mods.pop();
}
}
},
TextSpanBundle::Effect(effect) => {
entity.with_child(effect);
}
TextSpanBundle::Event(tag) => {
entity.with_child(tag);
}
TextSpanBundle::Callback(callback) => {
entity.with_child(callback);
}
}
}
mod sealed {
use std::borrow::Cow;
use bevy::ecs::spawn::SpawnableList;
use bevy::prelude::*;
use winnow::combinator::{alt, delimited, fail, opt, preceded, repeat, separated};
use winnow::error::{ContextError, ErrMode, ParserError, StrContext, StrContextValue};
use winnow::stream::Stream;
use winnow::token::take_while;
use winnow::{Parser, prelude::*};
use crate::dynamic_effects::PrettyTextEffect;
use crate::parser::{Modifier, Modifiers};
use crate::style::SpanStyle;
use crate::type_writer::hierarchy::{TypeWriterCallback, TypeWriterCommand, TypeWriterEvent};
use super::{Span, TextSpanBundle};
pub(super) struct TextSpanSpawner(std::vec::IntoIter<TextSpanBundle>);
impl TextSpanSpawner {
pub fn from_vec(spans: Vec<TextSpanBundle>) -> Self {
Self(spans.into_iter())
}
}
impl SpawnableList<ChildOf> for TextSpanSpawner {
fn spawn(self, world: &mut World, entity: Entity) {
let mut commands = world.commands();
let mut parent = commands.entity(entity);
for span in self.0 {
span.with_parent(&mut parent);
}
}
fn size_hint(&self) -> usize {
self.0.len()
}
}
pub(super) fn parse_bundles(pretty_text: &str) -> Result<Vec<TextSpanBundle>, String> {
tokenize
.parse(pretty_text)
.map_err(|err| err.to_string())
.and_then(|tokens| {
parse_tokens.parse(&tokens).map_err(|err| {
pretty_print_token_err(pretty_text, err.input(), err.offset(), err.inner())
})
})
}
fn parse_tokens(input: &mut &[Token]) -> ModalResult<Vec<TextSpanBundle>> {
repeat::<_, _, Vec<_>, _, _>(1.., text_components).parse_next(input)
}
fn text_components(input: &mut &[Token]) -> ModalResult<TextSpanBundle> {
alt((
speed,
pause,
normal_text,
styled_effect_text,
event,
fail.context(StrContext::Label("item")),
))
.parse_next(input)
}
fn speed(input: &mut &[Token]) -> ModalResult<TextSpanBundle> {
delimited(
Token::OpenAngle,
token_str.verify_map(|value| value.parse::<f32>().ok()),
Token::CloseAngle,
)
.map(|speed| TextSpanBundle::Effect(TypeWriterCommand::Speed(speed)))
.parse_next(input)
}
fn pause(input: &mut &[Token]) -> ModalResult<TextSpanBundle> {
delimited(
Token::OpenBracket,
token_str.verify_map(|value| value.parse::<f32>().ok()),
Token::CloseBracket,
)
.map(|dur| TextSpanBundle::Effect(TypeWriterCommand::Pause(dur)))
.parse_next(input)
}
#[derive(Default)]
struct RawTextAccumulator<'a>(smallvec::SmallVec<[&'a str; 3]>);
impl<'a> winnow::stream::Accumulate<&'a str> for RawTextAccumulator<'a> {
fn initial(capacity: Option<usize>) -> Self {
capacity
.map(|cap| Self(smallvec::SmallVec::with_capacity(cap)))
.unwrap_or_default()
}
fn accumulate(&mut self, acc: &'a str) {
self.0.push(acc);
}
}
fn raw_text(input: &mut &[Token]) -> ModalResult<Span> {
repeat(
1..,
alt((
token_str,
Token::Bang.map(|_| "!"),
Token::Comma.map(|_| ","),
Token::OpenParen.map(|_| "("),
Token::CloseParen.map(|_| ")"),
(Token::BackSlash, Token::BackTick).map(|_| "`"),
(Token::BackSlash, Token::OpenBracket).map(|_| "["),
(Token::BackSlash, Token::CloseBracket).map(|_| "]"),
(Token::BackSlash, Token::OpenCurly).map(|_| "{"),
(Token::BackSlash, Token::CloseCurly).map(|_| "}"),
(Token::BackSlash, Token::OpenAngle).map(|_| "<"),
(Token::BackSlash, Token::CloseAngle).map(|_| ">"),
)),
)
.map(|text: RawTextAccumulator| Span::Text(Cow::Owned(text.0.join(""))))
.parse_next(input)
}
fn normal_text(input: &mut &[Token]) -> ModalResult<TextSpanBundle> {
raw_text
.map(|span| TextSpanBundle::Span {
span,
mods: Modifiers::default(),
})
.parse_next(input)
}
fn styled_effect_text(input: &mut &[Token]) -> ModalResult<TextSpanBundle> {
(
preceded(Token::BackTick, repeat(1.., text_components)),
Token::BackTick,
delimited(Token::OpenBracket, mods, Token::CloseBracket),
)
.map(|(bundles, _, mods)| TextSpanBundle::Span {
span: Span::Bundles(bundles),
mods,
})
.parse_next(input)
}
fn mods(input: &mut &[Token]) -> ModalResult<Modifiers> {
let mods = separated(
1..,
alt((
preceded(
Token::Bang,
token_str
.verify(|str: &str| !str.trim().contains(char::is_whitespace))
.map(|str| String::from(str.trim()))
.context(StrContext::Label("modifier"))
.context(StrContext::Expected(StrContextValue::Description(
"a single modifier, e.g. `shake`",
))),
)
.map(|str| Modifier::Style(SpanStyle::Style(Cow::Owned(str)))),
(
token_str
.verify(|str: &str| !str.trim().contains(char::is_whitespace))
.map(|str| Cow::Owned(String::from(str.trim())))
.context(StrContext::Label("modifier"))
.context(StrContext::Expected(StrContextValue::Description(
"a single modifier, e.g. `shake`",
))),
opt(delimited(
Token::OpenParen,
separated(
1..,
token_str
.verify(|str: &str| !str.trim().contains(char::is_whitespace))
.map(|str| Cow::Owned(String::from(str.trim())))
.context(StrContext::Label("effect argument"))
.context(StrContext::Expected(StrContextValue::Description(
"comma separated list",
))),
Token::Comma,
)
.map_err(ErrMode::cut),
Token::CloseParen
.map_err(ErrMode::cut)
.context(StrContext::Label("closing delimiter")),
))
.map(|args: Option<Vec<Cow<'static, str>>>| args.unwrap_or_default()),
)
.map(|(tag, args)| Modifier::Effect(PrettyTextEffect { tag, args })),
)),
(
Token::Comma,
opt(token_str.verify(|str: &str| str.chars().all(char::is_whitespace))),
),
)
.parse_next(input)?;
if input
.peek_token()
.is_none_or(|token| !matches!(token, Token::CloseBracket))
{
fail.map_err(ErrMode::cut)
.context(StrContext::Label("text modifiers"))
.context(StrContext::Expected(StrContextValue::Description(
"comma separated list",
)))
.parse_next(input)?;
}
Ok(Modifiers(mods))
}
fn event(input: &mut &[Token]) -> ModalResult<TextSpanBundle> {
delimited(Token::OpenCurly, opt(token_str), Token::CloseCurly)
.map(|tag| {
tag.map(|tag| TextSpanBundle::Event(TypeWriterEvent(tag.to_string())))
.unwrap_or_else(|| TextSpanBundle::Callback(TypeWriterCallback::default()))
})
.parse_next(input)
}
fn token_str<'a>(input: &mut &'a [Token]) -> ModalResult<&'a str> {
match input.next_token() {
Some(Token::Text(str)) => Ok(str),
_ => fail.parse_next(input),
}
}
impl<'a> Parser<&[Token<'a>], Token<'a>, ErrMode<ContextError>> for Token<'a> {
fn parse_next(
&mut self,
input: &mut &[Token<'a>],
) -> winnow::Result<Token<'a>, ErrMode<ContextError>> {
(input.next_token() == Some(*self))
.then_some(*self)
.ok_or_else(|| ParserError::from_input(input))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Token<'a> {
Text(&'a str),
BackTick,
Bang,
Comma,
OpenBracket,
CloseBracket,
OpenAngle,
CloseAngle,
OpenCurly,
CloseCurly,
OpenParen,
CloseParen,
BackSlash,
}
impl Token<'_> {
fn as_str(&self) -> &str {
match self {
Self::Text(str) => str,
_ => self.as_static_str(),
}
}
fn as_static_str(&self) -> &'static str {
match self {
Self::Text(_) => "",
Self::BackTick => "`",
Self::Bang => "!",
Self::Comma => ",",
Self::OpenBracket => "[",
Self::CloseBracket => "]",
Self::OpenAngle => "<",
Self::CloseAngle => ">",
Self::OpenCurly => "{",
Self::CloseCurly => "}",
Self::OpenParen => "(",
Self::CloseParen => ")",
Self::BackSlash => "\\",
}
}
}
fn tokenize<'a>(input: &mut &'a str) -> ModalResult<Vec<Token<'a>>> {
repeat(0.., token).parse_next(input)
}
fn token<'a>(input: &mut &'a str) -> ModalResult<Token<'a>> {
let special_tokens = ['`', '!', '[', ']', '<', '>', '{', '}', '(', ')', ',', '\\'];
alt((
"`".map(|_| Token::BackTick),
"!".map(|_| Token::Bang),
"[".map(|_| Token::OpenBracket),
"]".map(|_| Token::CloseBracket),
"<".map(|_| Token::OpenAngle),
">".map(|_| Token::CloseAngle),
"{".map(|_| Token::OpenCurly),
"}".map(|_| Token::CloseCurly),
"(".map(|_| Token::OpenParen),
")".map(|_| Token::CloseParen),
",".map(|_| Token::Comma),
"\\".map(|_| Token::BackSlash),
take_while(1.., |c| !special_tokens.contains(&c)).map(Token::Text),
))
.parse_next(input)
}
fn pretty_print_token_err(
str_input: &str,
input: &[Token],
offset: usize,
ctx: &ContextError,
) -> String {
let mut arrow_str = String::new();
if offset >= input.len() {
for _ in 0..str_input.len() {
arrow_str.push(' ');
}
arrow_str.push('^');
} else {
let location = input[offset];
let offset = input
.iter()
.map(|token| token.as_str().chars().count())
.take(offset)
.sum();
for _ in 0..offset {
arrow_str.push(' ');
}
for _ in offset..offset + location.as_str().chars().count() {
arrow_str.push('^');
}
}
format!("failed to parse input\n{str_input}\n{arrow_str}\n{ctx}")
}
}
#[cfg(test)]
mod test {
use super::*;
#[track_caller]
fn assert_ok(str: &str) {
assert!(PrettyTextParser::bundle(str).is_ok());
}
#[track_caller]
fn assert_err(str: &str) {
assert!(PrettyTextParser::bundle(str).is_err());
}
#[test]
fn valid_parser_syntax() {
assert_ok("hello, world!");
assert_ok("`simple style`[!red]");
assert_ok("`simple style and effect`[!red, shake]");
assert_ok("simple{tag} tag");
assert_ok("`simple{tag} tag and effect`[shake]");
assert_ok("`effect args`[shake(1, \"str\", 9.232)]");
assert_ok("`recursive `effect`[wave]`[shake]");
assert_ok("`recursive `effect`[wave] and `style`[!red]`[shake]");
assert_ok("escaped \\`\\` ticks");
}
#[test]
fn invalid_parser_syntax() {
assert_err("`unclosed");
assert_err("unclosed`");
assert_err("`unclosed`[wave");
assert_err("`unclosed`wave]");
assert_err("`unclosed`[wave(]");
assert_err("`unclosed`[wave)]");
assert_err("`no comma`[wave(1 2)]");
assert_err("`many commas`[wave(1,, 2)]");
assert_err("`empty mods`[]");
assert_err("`wave and scramble`[wave(1, 20) scramble][2]");
assert_err("``unclosed`");
assert_err("`unclosed``");
assert_err("unclosed{");
assert_err("unclosed}");
assert_err("{`styled`[!red]}");
}
}