diff --git a/.ecode/project_build.json b/.ecode/project_build.json index 9eb5a84c0..2ff6d5174 100644 --- a/.ecode/project_build.json +++ b/.ecode/project_build.json @@ -357,6 +357,12 @@ "command": "${project_root}/bin/eepp-ui-application-hello-world", "name": "eepp-ui-application-hello-world-debug", "working_dir": "${project_root}/bin" + }, + { + "args": "", + "command": "${project_root}/bin/eepp-ui-richtext-debug", + "name": "eepp-ui-richtext-debug", + "working_dir": "${project_root}/bin" } ], "var": { diff --git a/.gitignore b/.gitignore index 5c01e2842..c19a8dec8 100644 --- a/.gitignore +++ b/.gitignore @@ -80,3 +80,4 @@ ecode.dmg /projects/android-project/app/.classpath /projects/android-project/.project /projects/android-project/app/.project +/design/ diff --git a/include/eepp/graphics/richtext.hpp b/include/eepp/graphics/richtext.hpp index 8cfa66f6a..f65d4216f 100644 --- a/include/eepp/graphics/richtext.hpp +++ b/include/eepp/graphics/richtext.hpp @@ -55,14 +55,38 @@ class EE_API RichText : public Drawable { /** @brief Sets the text alignment (Left, Center, Right). */ void setAlign( Uint32 align ); + /** @return The text alignment. */ + Uint32 getAlign() const { return mAlign; } + /** @brief Sets the maximum width for wrapping. If 0, wrapping is disabled. */ void setMaxWidth( Float width ); /** @return The maximum width for wrapping. */ Float getMaxWidth() const { return mMaxWidth; } - /** @return The list of text spans. */ - std::vector>& getSpans() { return mSpans; } + enum class BlockType { Text, Drawable, CustomSize }; + + struct Block { + BlockType type{ BlockType::Text }; + std::shared_ptr text; + std::shared_ptr drawable; + Sizef customSize; + }; + + /** + * @brief Adds a drawable (e.g., an image) into the text flow. + * @param drawable The drawable to add. + */ + void addDrawable( std::shared_ptr drawable ); + + /** + * @brief Adds a custom size spacer into the text flow. + * @param size The physical dimensions of the spacer. + */ + void addCustomSize( const Sizef& size ); + + /** @return The list of blocks. */ + const std::vector& getBlocks() { return mBlocks; } virtual void draw( const Float& X, const Float& Y, const Vector2f& scale = Vector2f::One, const Float& rotation = 0, BlendMode effect = BlendMode::Alpha(), @@ -86,8 +110,9 @@ class EE_API RichText : public Drawable { /** @brief Structure representing a rendered span within a line. */ struct RenderSpan { - std::shared_ptr text; + Block block; Vector2f position; // Local position relative to RichText origin + Sizef size; }; /** @brief Structure representing a rendered paragraph (line). */ @@ -103,7 +128,7 @@ class EE_API RichText : public Drawable { const std::vector& getLines() const { return mLines; } protected: - std::vector> mSpans; + std::vector mBlocks; std::vector mLines; FontStyleConfig mDefaultStyle; Uint32 mAlign{ TEXT_ALIGN_LEFT }; diff --git a/include/eepp/ui/uihelper.hpp b/include/eepp/ui/uihelper.hpp index 0f21eea54..48087a562 100644 --- a/include/eepp/ui/uihelper.hpp +++ b/include/eepp/ui/uihelper.hpp @@ -43,6 +43,7 @@ enum UIFlag : Uint32 { UI_SCROLLABLE = ( 1 << 27 ), UI_HIGHLIGHT = ( 1 << 28 ), UI_PARENT_ATTRIBUTE_CHANGED = ( 1 << 29 ), + UI_LOADS_ITS_CHILDREN = ( 1 << 30 ), }; enum UINodeType { @@ -107,6 +108,8 @@ enum UINodeType { UI_TYPE_IMAGE_VIEWER, UI_TYPE_AUDIO_PLAYER, UI_TYPE_NODELINK, + UI_TYPE_TEXTSPAN, + UI_TYPE_RICHTEXT, UI_TYPE_MODULES = 10000, UI_TYPE_TERMINAL = 10001, UI_TYPE_USER = 200000, diff --git a/include/eepp/ui/uirichtext.hpp b/include/eepp/ui/uirichtext.hpp new file mode 100644 index 000000000..1641413b0 --- /dev/null +++ b/include/eepp/ui/uirichtext.hpp @@ -0,0 +1,89 @@ +#ifndef EE_UI_UIRICHTEXT_HPP +#define EE_UI_UIRICHTEXT_HPP + +#include +#include + +namespace EE { namespace UI { + +class EE_API UIRichText : public UILayout { + public: + static UIRichText* New(); + + static UIRichText* NewWithTag( const std::string& tag ); + + explicit UIRichText( const std::string& tag = "richtext" ); + + virtual ~UIRichText(); + + virtual Uint32 getType() const; + + virtual bool isType( const Uint32& type ) const; + + virtual void draw(); + + virtual void loadFromXmlNode( const pugi::xml_node& node ); + + virtual bool applyProperty( const StyleSheetProperty& attribute ); + + virtual std::string getPropertyString( const PropertyDefinition* propertyDef, + const Uint32& propertyIndex = 0 ) const; + + virtual std::vector getPropertiesImplemented() const; + + Graphics::RichText* getRichText(); + + Graphics::Font* getFont() const; + + UIRichText* setFont( Graphics::Font* font ); + + Uint32 getFontSize() const; + + UIRichText* setFontSize( const Uint32& characterSize ); + + const Uint32& getFontStyle() const; + + UIRichText* setFontStyle( const Uint32& fontStyle ); + + const Color& getFontColor() const; + + UIRichText* setFontColor( const Color& color ); + + const Color& getFontShadowColor() const; + + UIRichText* setFontShadowColor( const Color& color ); + + const Vector2f& getFontShadowOffset() const; + + UIRichText* setFontShadowOffset( const Vector2f& offset ); + + const Float& getOutlineThickness() const; + + UIRichText* setOutlineThickness( const Float& outlineThickness ); + + const Color& getOutlineColor() const; + + UIRichText* setOutlineColor( const Color& outlineColor ); + + Uint32 getTextAlign() const; + + UIRichText* setTextAlign( const Uint32& align ); + + protected: + Graphics::RichText* mRichText; + + virtual void onSizeChange(); + virtual void onPaddingChange(); + virtual void onLayoutUpdate(); + virtual void onChildCountChange( Node* child, const bool& removed ); + virtual void onFontChanged(); + virtual void onFontStyleChanged(); + virtual void onAlphaChange(); + + void rebuildRichText(); + void positionChildren(); +}; + +}} // namespace EE::UI + +#endif diff --git a/include/eepp/ui/uitextspan.hpp b/include/eepp/ui/uitextspan.hpp new file mode 100644 index 000000000..4daaf2955 --- /dev/null +++ b/include/eepp/ui/uitextspan.hpp @@ -0,0 +1,86 @@ +#ifndef EE_UI_UITEXTSPAN_HPP +#define EE_UI_UITEXTSPAN_HPP + +#include +#include + +namespace EE { namespace UI { + +class EE_API UITextSpan : public UIWidget { + public: + static UITextSpan* New(); + + static UITextSpan* NewWithTag( const std::string& tag ); + + virtual ~UITextSpan(); + + virtual Uint32 getType() const; + + virtual bool isType( const Uint32& type ) const; + + virtual void draw(); + + virtual bool applyProperty( const StyleSheetProperty& attribute ); + + virtual std::string getPropertyString( const PropertyDefinition* propertyDef, + const Uint32& propertyIndex = 0 ) const; + + virtual std::vector getPropertiesImplemented() const; + + const String& getText() const; + + UITextSpan* setText( const String& text ); + + const UIFontStyleConfig& getFontStyleConfig() const; + + virtual void loadFromXmlNode( const pugi::xml_node& node ); + + void setFontStyleConfig( const UIFontStyleConfig& fontStyleConfig ); + + Graphics::Font* getFont() const; + + UITextSpan* setFont( Graphics::Font* font ); + + Uint32 getFontSize() const; + + UITextSpan* setFontSize( const Uint32& characterSize ); + + const Uint32& getFontStyle() const; + + UITextSpan* setFontStyle( const Uint32& fontStyle ); + + const Float& getOutlineThickness() const; + + UITextSpan* setOutlineThickness( const Float& outlineThickness ); + + const Color& getOutlineColor() const; + + UITextSpan* setOutlineColor( const Color& outlineColor ); + + const Color& getFontColor() const; + + UITextSpan* setFontColor( const Color& color ); + + const Color& getFontShadowColor() const; + + UITextSpan* setFontShadowColor( const Color& color ); + + const Vector2f& getFontShadowOffset() const; + + UITextSpan* setFontShadowOffset( const Vector2f& offset ); + + protected: + String mText; + UIFontStyleConfig mFontStyleConfig; + + explicit UITextSpan( const std::string& tag = "span" ); + + virtual void onAlphaChange(); + virtual void onFontChanged(); + virtual void onFontStyleChanged(); + virtual void onTextChanged(); +}; + +}} // namespace EE::UI + +#endif diff --git a/include/eepp/ui/uiwidget.hpp b/include/eepp/ui/uiwidget.hpp index b78210b18..434be3287 100644 --- a/include/eepp/ui/uiwidget.hpp +++ b/include/eepp/ui/uiwidget.hpp @@ -496,6 +496,11 @@ class EE_API UIWidget : public UINode { */ virtual void loadFromXmlNode( const pugi::xml_node& node ); + /** + * @brief Boolean that indicates if the widget is in charge of loading its children nodes + */ + bool loadsItsChildren() const; + /** * @brief Notifies that layout attributes have changed. * diff --git a/premake4.lua b/premake4.lua index e9c371b1e..be3439775 100644 --- a/premake4.lua +++ b/premake4.lua @@ -1574,6 +1574,12 @@ solution "eepp" files { "src/examples/ui_application_hello_world/*.cpp" } build_link_configuration( "eepp-ui-application-hello-world", true ) + project "eepp-ui-richtext" + set_kind() + language "C++" + files { "src/examples/ui_richtext/*.cpp" } + build_link_configuration( "eepp-ui-richtext", true ) + project "eepp-richtext" set_kind() language "C++" diff --git a/premake5.lua b/premake5.lua index baad33e4e..179548afd 100644 --- a/premake5.lua +++ b/premake5.lua @@ -1458,6 +1458,12 @@ workspace "eepp" files { "src/examples/ui_application_hello_world/*.cpp" } build_link_configuration( "eepp-ui-application-hello-world", true ) + project "eepp-ui-richtext" + set_kind() + language "C++" + files { "src/examples/ui_richtext/*.cpp" } + build_link_configuration( "eepp-ui-richtext", true ) + project "eepp-richtext" set_kind() language "C++" diff --git a/src/eepp/graphics/richtext.cpp b/src/eepp/graphics/richtext.cpp index 891e1b873..2c7e4a515 100644 --- a/src/eepp/graphics/richtext.cpp +++ b/src/eepp/graphics/richtext.cpp @@ -42,13 +42,20 @@ void RichText::draw( const Float& X, const Float& Y, const Vector2f& scale, cons Vector2f pos = span.position; - if ( rotation == 0 && scale == Vector2f::One ) { - span.text->draw( std::trunc( X + pos.x ), std::trunc( Y + line.y + pos.y ), - Vector2f::One, 0, effect ); - } else { - span.text->draw( std::trunc( X + pos.x * scale.x ), - std::trunc( Y + ( line.y + pos.y ) * scale.y ), scale, rotation, - effect, rotationCenter, scaleCenter ); + if ( span.block.type == BlockType::Text ) { + if ( rotation == 0 && scale == Vector2f::One ) { + span.block.text->draw( std::trunc( X + pos.x ), + std::trunc( Y + line.y + pos.y ), Vector2f::One, 0, + effect ); + } else { + span.block.text->draw( std::trunc( X + pos.x * scale.x ), + std::trunc( Y + ( line.y + pos.y ) * scale.y ), scale, + rotation, effect, rotationCenter, scaleCenter ); + } + } else if ( span.block.type == BlockType::Drawable && span.block.drawable ) { + span.block.drawable->draw( + Vector2f( std::trunc( X + pos.x ), std::trunc( Y + line.y + pos.y ) ), + span.size ); } } } @@ -65,7 +72,28 @@ void RichText::addSpan( const String& text, const FontStyleConfig& style ) { auto span = std::make_shared(); span->setString( text ); span->setStyleConfig( style ); - mSpans.push_back( span ); + Block block; + block.type = BlockType::Text; + block.text = span; + mBlocks.push_back( block ); + mNeedsLayoutUpdate = true; +} + +void RichText::addDrawable( std::shared_ptr drawable ) { + if ( !drawable ) + return; + Block block; + block.type = BlockType::Drawable; + block.drawable = drawable; + mBlocks.push_back( block ); + mNeedsLayoutUpdate = true; +} + +void RichText::addCustomSize( const Sizef& size ) { + Block block; + block.type = BlockType::CustomSize; + block.customSize = size; + mBlocks.push_back( block ); mNeedsLayoutUpdate = true; } @@ -85,7 +113,7 @@ void RichText::addSpan( const String& text, Font* font, Uint32 characterSize, Co } void RichText::clear() { - mSpans.clear(); + mBlocks.clear(); mLines.clear(); mNeedsLayoutUpdate = true; } @@ -111,8 +139,10 @@ void RichText::setMaxWidth( Float width ) { void RichText::invalidate() { mNeedsLayoutUpdate = true; - for ( auto& span : mSpans ) { - span->invalidate(); + for ( auto& block : mBlocks ) { + if ( block.type == BlockType::Text && block.text ) { + block.text->invalidate(); + } } } @@ -126,62 +156,96 @@ void RichText::updateLayout() { Float curX = 0; Float maxWidth = 0; - for ( auto& span : mSpans ) { - if ( span->getString().empty() ) - continue; + for ( auto& block : mBlocks ) { + if ( block.type == BlockType::Text ) { + auto& span = block.text; + if ( !span || span->getString().empty() ) + continue; - auto& fontStyle = span->getFontStyleConfig(); - if ( !fontStyle.Font ) - continue; + auto& fontStyle = span->getFontStyleConfig(); + if ( !fontStyle.Font ) + continue; - Uint32 textHints = span->getTextHints(); + Uint32 textHints = span->getTextHints(); - LineWrapInfoEx wrapInfo = LineWrap::computeLineBreaksEx( - span->getString(), fontStyle, mMaxWidth > 0 ? mMaxWidth : 1e9f, - mMaxWidth > 0 ? LineWrapMode::Word : LineWrapMode::NoWrap, false, 4, 0.f, textHints, - false, curX ); + LineWrapInfoEx wrapInfo = LineWrap::computeLineBreaksEx( + span->getString(), fontStyle, mMaxWidth > 0 ? mMaxWidth : 1e9f, + mMaxWidth > 0 ? LineWrapMode::Word : LineWrapMode::NoWrap, false, 4, 0.f, textHints, + false, curX ); - // Make sure we have the end of the string as a "wrap" point for the loop - if ( wrapInfo.wraps.empty() || wrapInfo.wraps.back() != (Float)span->getString().size() ) - wrapInfo.wraps.push_back( span->getString().size() ); + // Make sure we have the end of the string as a "wrap" point for the loop + if ( wrapInfo.wraps.empty() || + wrapInfo.wraps.back() != (Float)span->getString().size() ) + wrapInfo.wraps.push_back( span->getString().size() ); - for ( size_t i = 0; i < wrapInfo.wraps.size() - 1; ++i ) { - size_t startIdx = wrapInfo.wraps[i]; - size_t endIdx = wrapInfo.wraps[i + 1]; - bool isNewline = ( endIdx - startIdx == 1 && span->getString()[startIdx] == '\n' ); + for ( size_t i = 0; i < wrapInfo.wraps.size() - 1; ++i ) { + size_t startIdx = wrapInfo.wraps[i]; + size_t endIdx = wrapInfo.wraps[i + 1]; + bool isNewline = ( endIdx - startIdx == 1 && span->getString()[startIdx] == '\n' ); - if ( !isNewline ) { - std::shared_ptr renderSpanText = std::make_shared(); - renderSpanText->setString( - span->getString().substr( startIdx, endIdx - startIdx ) ); - renderSpanText->setStyleConfig( fontStyle ); + if ( !isNewline ) { + std::shared_ptr renderSpanText = std::make_shared(); + renderSpanText->setString( + span->getString().substr( startIdx, endIdx - startIdx ) ); + renderSpanText->setStyleConfig( fontStyle ); - RenderSpan renderSpan; - renderSpan.text = renderSpanText; - renderSpan.position = { curX, 0 }; // Y adjusted later + Block newBlock; + newBlock.type = BlockType::Text; + newBlock.text = renderSpanText; - RenderParagraph& currentLine = mLines.back(); - currentLine.spans.push_back( renderSpan ); + RenderSpan renderSpan; + renderSpan.block = newBlock; + renderSpan.position = { curX, 0 }; // Y adjusted later - Float ascent = fontStyle.Font->getAscent( fontStyle.CharacterSize ); - Float height = fontStyle.Font->getLineSpacing( fontStyle.CharacterSize ); + RenderParagraph& currentLine = mLines.back(); + currentLine.spans.push_back( renderSpan ); - currentLine.maxAscent = std::max( currentLine.maxAscent, ascent ); - currentLine.height = std::max( currentLine.height, height ); + Float ascent = fontStyle.Font->getAscent( fontStyle.CharacterSize ); + Float height = fontStyle.Font->getLineSpacing( fontStyle.CharacterSize ); - Float spanWidth = renderSpan.text->getTextWidth(); - curX += spanWidth; - currentLine.width += spanWidth; + currentLine.maxAscent = std::max( currentLine.maxAscent, ascent ); + currentLine.height = std::max( currentLine.height, height ); + + Float spanWidth = renderSpan.block.text->getTextWidth(); + renderSpan.size = Sizef( spanWidth, height ); + curX += spanWidth; + currentLine.width += spanWidth; + } + + // If it's a newline, or if it's not the very last segment (which means it wrapped), + // start a new line. Exception: If the last segment was just a newline, we already + // handled it. + if ( i < wrapInfo.wraps.size() - 2 || isNewline ) { + maxWidth = std::max( maxWidth, curX ); + mLines.push_back( RenderParagraph() ); + curX = 0; + } } + } else if ( block.type == BlockType::Drawable || block.type == BlockType::CustomSize ) { + Sizef blockSize = block.type == BlockType::Drawable + ? ( block.drawable ? block.drawable->getPixelsSize() : Sizef() ) + : block.customSize; - // If it's a newline, or if it's not the very last segment (which means it wrapped), - // start a new line. Exception: If the last segment was just a newline, we already - // handled it. - if ( i < wrapInfo.wraps.size() - 2 || isNewline ) { + // Wrap if needed + if ( mMaxWidth > 0 && curX + blockSize.getWidth() > mMaxWidth && curX > 0 ) { maxWidth = std::max( maxWidth, curX ); mLines.push_back( RenderParagraph() ); curX = 0; } + + RenderSpan renderSpan; + renderSpan.block = block; + renderSpan.position = { curX, 0 }; + renderSpan.size = blockSize; + + RenderParagraph& currentLine = mLines.back(); + currentLine.spans.push_back( renderSpan ); + + currentLine.maxAscent = std::max( currentLine.maxAscent, blockSize.getHeight() ); + currentLine.height = std::max( currentLine.height, blockSize.getHeight() ); + + curX += blockSize.getWidth(); + currentLine.width += blockSize.getWidth(); } } @@ -206,10 +270,19 @@ void RichText::updateLayout() { } for ( auto& span : line.spans ) { - Float ascent = span.text->getFont()->getAscent( span.text->getCharacterSize() ); - Float offsetY = line.maxAscent - ascent; - span.position.x += xOffset; - span.position.y = offsetY; + if ( span.block.type == BlockType::Text ) { + Float ascent = + span.block.text->getFont()->getAscent( span.block.text->getCharacterSize() ); + Float offsetY = line.maxAscent - ascent; + span.position.x += xOffset; + span.position.y = offsetY; + } else { + Float offsetY = line.height - span.size.getHeight(); + if ( offsetY < 0 ) + offsetY = 0; + span.position.x += xOffset; + span.position.y = offsetY; + } } curY += line.height; diff --git a/src/eepp/ui/uirichtext.cpp b/src/eepp/ui/uirichtext.cpp new file mode 100644 index 000000000..9513645ca --- /dev/null +++ b/src/eepp/ui/uirichtext.cpp @@ -0,0 +1,461 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#define PUGIXML_HEADER_ONLY +#include + +namespace EE { namespace UI { + +UIRichText* UIRichText::New() { + return eeNew( UIRichText, () ); +} + +UIRichText* UIRichText::NewWithTag( const std::string& tag ) { + return eeNew( UIRichText, ( tag ) ); +} + +UIRichText::UIRichText( const std::string& tag ) : + UILayout( tag ), mRichText( Graphics::RichText::New() ) { + mFlags |= UI_LOADS_ITS_CHILDREN; + + UITheme* theme = getUISceneNode()->getUIThemeManager()->getDefaultTheme(); + + if ( NULL != theme && NULL != theme->getDefaultFont() ) { + mRichText->getFontStyleConfig().Font = theme->getDefaultFont(); + } else if ( NULL != getUISceneNode()->getUIThemeManager()->getDefaultFont() ) { + mRichText->getFontStyleConfig().Font = + getUISceneNode()->getUIThemeManager()->getDefaultFont(); + } + + if ( NULL != theme ) { + mRichText->getFontStyleConfig().CharacterSize = theme->getDefaultFontSize(); + } else { + mRichText->getFontStyleConfig().CharacterSize = + getUISceneNode()->getUIThemeManager()->getDefaultFontSize(); + } +} + +UIRichText::~UIRichText() { + eeDelete( mRichText ); +} + +Uint32 UIRichText::getType() const { + return UI_TYPE_RICHTEXT; +} + +bool UIRichText::isType( const Uint32& type ) const { + return UIRichText::getType() == type ? true : UILayout::isType( type ); +} + +Graphics::RichText* UIRichText::getRichText() { + return mRichText; +} + +void UIRichText::draw() { + if ( mVisible && 0.f != mAlpha ) { + UIWidget::draw(); + + if ( mRichText->getSize().getWidth() > 0.f ) { + if ( isClipped() ) { + clipSmartEnable( mScreenPos.x + mPaddingPx.Left, mScreenPos.y + mPaddingPx.Top, + mSize.getWidth() - mPaddingPx.Left - mPaddingPx.Right, + mSize.getHeight() - mPaddingPx.Top - mPaddingPx.Bottom ); + } + + mRichText->draw( std::trunc( mScreenPos.x ) + (int)mPaddingPx.Left, + std::trunc( mScreenPos.y ) + (int)mPaddingPx.Top, Vector2f::One, 0.f, + getBlendMode() ); + + if ( isClipped() ) + clipSmartDisable(); + } + } +} + +bool UIRichText::applyProperty( const StyleSheetProperty& attribute ) { + if ( !checkPropertyDefinition( attribute ) ) + return false; + + switch ( attribute.getPropertyDefinition()->getPropertyId() ) { + case PropertyId::FontFamily: { + Graphics::Font* font = + Graphics::FontManager::instance()->getByName( attribute.value() ); + if ( NULL != font && font->loaded() ) { + setFont( font ); + } + break; + } + case PropertyId::FontSize: + setFontSize( lengthFromValue( attribute ) ); + break; + case PropertyId::FontStyle: + setFontStyle( attribute.asFontStyle() ); + break; + case PropertyId::Color: + setFontColor( attribute.asColor() ); + break; + case PropertyId::TextShadowColor: + setFontShadowColor( attribute.asColor() ); + break; + case PropertyId::TextShadowOffset: + setFontShadowOffset( attribute.asVector2f() ); + break; + case PropertyId::TextStrokeWidth: + setOutlineThickness( lengthFromValue( attribute ) ); + break; + case PropertyId::TextStrokeColor: + setOutlineColor( attribute.asColor() ); + break; + case PropertyId::TextAlign: { + std::string align = String::toLower( attribute.value() ); + if ( align == "center" ) + setTextAlign( TEXT_ALIGN_CENTER ); + else if ( align == "left" ) + setTextAlign( TEXT_ALIGN_LEFT ); + else if ( align == "right" ) + setTextAlign( TEXT_ALIGN_RIGHT ); + break; + } + default: + return UILayout::applyProperty( attribute ); + } + + return true; +} + +std::string UIRichText::getPropertyString( const PropertyDefinition* propertyDef, + const Uint32& propertyIndex ) const { + if ( NULL == propertyDef ) + return ""; + + switch ( propertyDef->getPropertyId() ) { + case PropertyId::FontFamily: + return NULL != getFont() ? getFont()->getName() : ""; + case PropertyId::FontSize: + return String::format( "%dpx", getFontSize() ); + case PropertyId::FontStyle: + return Graphics::Text::styleFlagToString( getFontStyle() ); + case PropertyId::Color: + return getFontColor().toHexString(); + case PropertyId::TextShadowColor: + return getFontShadowColor().toHexString(); + case PropertyId::TextShadowOffset: + return String::fromFloat( getFontShadowOffset().x ) + " " + + String::fromFloat( getFontShadowOffset().y ); + case PropertyId::TextStrokeWidth: + return String::fromFloat( PixelDensity::dpToPx( getOutlineThickness() ), "px" ); + case PropertyId::TextStrokeColor: + return getOutlineColor().toHexString(); + case PropertyId::TextAlign: + return getTextAlign() == TEXT_ALIGN_CENTER + ? "center" + : ( getTextAlign() == TEXT_ALIGN_RIGHT ? "right" : "left" ); + default: + return UILayout::getPropertyString( propertyDef, propertyIndex ); + } +} + +std::vector UIRichText::getPropertiesImplemented() const { + auto props = UILayout::getPropertiesImplemented(); + auto local = { + PropertyId::FontFamily, PropertyId::FontSize, PropertyId::FontStyle, + PropertyId::Color, PropertyId::TextShadowColor, PropertyId::TextShadowOffset, + PropertyId::TextStrokeWidth, PropertyId::TextStrokeColor, PropertyId::TextAlign }; + props.insert( props.end(), local.begin(), local.end() ); + return props; +} + +Graphics::Font* UIRichText::getFont() const { + return mRichText->getFontStyleConfig().Font; +} + +UIRichText* UIRichText::setFont( Graphics::Font* font ) { + if ( NULL != font && mRichText->getFontStyleConfig().Font != font ) { + mRichText->getFontStyleConfig().Font = font; + mRichText->invalidate(); + setLayoutDirty(); + } + return this; +} + +Uint32 UIRichText::getFontSize() const { + return mRichText->getFontStyleConfig().CharacterSize; +} + +UIRichText* UIRichText::setFontSize( const Uint32& characterSize ) { + if ( mRichText->getFontStyleConfig().CharacterSize != characterSize ) { + mRichText->getFontStyleConfig().CharacterSize = characterSize; + mRichText->invalidate(); + setLayoutDirty(); + } + return this; +} + +const Uint32& UIRichText::getFontStyle() const { + return mRichText->getFontStyleConfig().Style; +} + +UIRichText* UIRichText::setFontStyle( const Uint32& fontStyle ) { + if ( mRichText->getFontStyleConfig().Style != fontStyle ) { + mRichText->getFontStyleConfig().Style = fontStyle; + mRichText->invalidate(); + setLayoutDirty(); + } + return this; +} + +const Color& UIRichText::getFontColor() const { + return mRichText->getFontStyleConfig().FontColor; +} + +UIRichText* UIRichText::setFontColor( const Color& color ) { + if ( mRichText->getFontStyleConfig().FontColor != color ) { + mRichText->getFontStyleConfig().FontColor = color; + mRichText->invalidate(); + } + return this; +} + +const Color& UIRichText::getFontShadowColor() const { + return mRichText->getFontStyleConfig().ShadowColor; +} + +UIRichText* UIRichText::setFontShadowColor( const Color& color ) { + if ( mRichText->getFontStyleConfig().ShadowColor != color ) { + mRichText->getFontStyleConfig().ShadowColor = color; + if ( mRichText->getFontStyleConfig().ShadowColor != Color::Transparent ) + mRichText->getFontStyleConfig().Style |= Graphics::Text::Shadow; + else + mRichText->getFontStyleConfig().Style &= ~Graphics::Text::Shadow; + mRichText->invalidate(); + } + return this; +} + +const Vector2f& UIRichText::getFontShadowOffset() const { + return mRichText->getFontStyleConfig().ShadowOffset; +} + +UIRichText* UIRichText::setFontShadowOffset( const Vector2f& offset ) { + if ( mRichText->getFontStyleConfig().ShadowOffset != offset ) { + mRichText->getFontStyleConfig().ShadowOffset = offset; + mRichText->invalidate(); + } + return this; +} + +const Float& UIRichText::getOutlineThickness() const { + return mRichText->getFontStyleConfig().OutlineThickness; +} + +UIRichText* UIRichText::setOutlineThickness( const Float& outlineThickness ) { + if ( mRichText->getFontStyleConfig().OutlineThickness != outlineThickness ) { + mRichText->getFontStyleConfig().OutlineThickness = outlineThickness; + mRichText->invalidate(); + setLayoutDirty(); + } + return this; +} + +const Color& UIRichText::getOutlineColor() const { + return mRichText->getFontStyleConfig().OutlineColor; +} + +UIRichText* UIRichText::setOutlineColor( const Color& outlineColor ) { + if ( mRichText->getFontStyleConfig().OutlineColor != outlineColor ) { + mRichText->getFontStyleConfig().OutlineColor = outlineColor; + mRichText->invalidate(); + } + return this; +} + +Uint32 UIRichText::getTextAlign() const { + return mRichText->getAlign(); +} + +UIRichText* UIRichText::setTextAlign( const Uint32& align ) { + if ( mRichText->getAlign() != align ) { + mRichText->setAlign( align ); + setLayoutDirty(); + } + return this; +} + +void UIRichText::loadFromXmlNode( const pugi::xml_node& node ) { + beginAttributesTransaction(); + + UIWidget::loadFromXmlNode( node ); + + auto collapseXmlWhitespace = []( const String& text ) -> String { + String res; + res.reserve( text.size() ); + bool inSpace = false; + for ( size_t i = 0; i < text.size(); ++i ) { + if ( text[i] == ' ' || text[i] == '\t' || text[i] == '\n' || text[i] == '\r' || + text[i] == '\v' ) { + if ( !inSpace ) { + res += ' '; + inSpace = true; + } + } else { + res += text[i]; + inSpace = false; + } + } + return res; + }; + + for ( pugi::xml_node child = node.first_child(); child; child = child.next_sibling() ) { + if ( child.type() == pugi::node_element ) { + if ( String::iequals( child.name(), "span" ) || + String::iequals( child.name(), "textspan" ) ) { + UITextSpan* span = UITextSpan::New(); + span->setParent( this ); + span->loadFromXmlNode( child ); + } else { + // Let parent logic load standard child widget + UIWidget* widget = UIWidgetCreator::createFromName( child.name() ); + if ( widget ) { + widget->setParent( this ); + widget->loadFromXmlNode( child ); + } + } + } else if ( child.type() == pugi::node_pcdata ) { + String text = collapseXmlWhitespace( getTranslatorString( child.value() ) ); + if ( !text.empty() ) { + UITextSpan* span = UITextSpan::New(); + span->setParent( this ); + span->setText( text ); + } + } + } + + endAttributesTransaction(); + setLayoutDirty(); +} + +void UIRichText::onSizeChange() { + UILayout::onSizeChange(); + setLayoutDirty(); // Re-wrap if size changes +} + +void UIRichText::onPaddingChange() { + UILayout::onPaddingChange(); + setLayoutDirty(); +} + +void UIRichText::onChildCountChange( Node* child, const bool& removed ) { + UILayout::onChildCountChange( child, removed ); + setLayoutDirty(); +} + +void UIRichText::onFontChanged() { + setLayoutDirty(); +} + +void UIRichText::onFontStyleChanged() { + setLayoutDirty(); +} + +void UIRichText::onAlphaChange() { + UILayout::onAlphaChange(); +} + +void UIRichText::rebuildRichText() { + mRichText->clear(); + + // Calculate maximum layout width for the RichText block + Float maxWidth = mSize.getWidth() - mPaddingPx.Left - mPaddingPx.Right; + if ( maxWidth < 0 ) + maxWidth = 0; + if ( mWidthPolicy == SizePolicy::WrapContent ) { + mRichText->setMaxWidth( 0.f ); // Let it grow unbounded to query text bounds later + } else { + mRichText->setMaxWidth( maxWidth ); + } + + Node* child = mChild; + while ( NULL != child ) { + if ( child->isWidget() ) { + UIWidget* widget = static_cast( child ); + if ( widget->isType( UI_TYPE_TEXTSPAN ) ) { + UITextSpan* span = static_cast( widget ); + mRichText->addSpan( span->getText(), span->getFontStyleConfig() ); + } else { + mRichText->addCustomSize( widget->getPixelsSize() ); + } + } + child = child->getNextNode(); + } +} + +void UIRichText::positionChildren() { + const auto& lines = mRichText->getLines(); + + Node* child = mChild; + + size_t currentLine = 0; + size_t currentSpan = 0; + + // Helper to find the next RenderSpan of type CustomSize + auto getNextCustomSpan = [&]() -> const Graphics::RichText::RenderSpan* { + while ( currentLine < lines.size() ) { + const auto& line = lines[currentLine]; + while ( currentSpan < line.spans.size() ) { + const auto& span = line.spans[currentSpan]; + currentSpan++; + if ( span.block.type == Graphics::RichText::BlockType::CustomSize ) { + return &span; + } + } + currentSpan = 0; + currentLine++; + } + return nullptr; + }; + + while ( NULL != child ) { + if ( child->isWidget() ) { + UIWidget* widget = static_cast( child ); + if ( !widget->isType( UI_TYPE_TEXTSPAN ) ) { + const auto* span = getNextCustomSpan(); + if ( span ) { + size_t lineIdx = currentSpan > 0 ? currentLine : currentLine - 1; + Float lineY = lines[lineIdx].y; + + widget->setPixelsPosition( mPaddingPx.Left + span->position.x, + mPaddingPx.Top + lineY + span->position.y ); + } + } + } + child = child->getNextNode(); + } +} + +void UIRichText::onLayoutUpdate() { + rebuildRichText(); + + mRichText->getSize(); // Forces an updateLayout internally + + positionChildren(); + + // Resize logic + if ( mWidthPolicy == SizePolicy::WrapContent ) { + setInternalPixelsWidth( mRichText->getSize().getWidth() + mPaddingPx.Left + + mPaddingPx.Right ); + } + if ( mHeightPolicy == SizePolicy::WrapContent ) { + setInternalPixelsHeight( mRichText->getSize().getHeight() + mPaddingPx.Top + + mPaddingPx.Bottom ); + } + + UILayout::onLayoutUpdate(); +} + +}} // namespace EE::UI diff --git a/src/eepp/ui/uiscenenode.cpp b/src/eepp/ui/uiscenenode.cpp index 085ba86bf..ec1c35fb3 100644 --- a/src/eepp/ui/uiscenenode.cpp +++ b/src/eepp/ui/uiscenenode.cpp @@ -292,7 +292,7 @@ std::vector UISceneNode::loadNode( pugi::xml_node node, Node* parent, clock.getElapsedTime().asMilliseconds(), std::string( name ) ) ); } - if ( widget.first_child() ) { + if ( widget.first_child() && !uiwidget->loadsItsChildren() ) { loadNode( widget.first_child(), uiwidget, marker ); } diff --git a/src/eepp/ui/uitextspan.cpp b/src/eepp/ui/uitextspan.cpp new file mode 100644 index 000000000..2949acf24 --- /dev/null +++ b/src/eepp/ui/uitextspan.cpp @@ -0,0 +1,299 @@ +#include +#include +#include +#include +#include +#include +#define PUGIXML_HEADER_ONLY +#include + +namespace EE { namespace UI { + +UITextSpan* UITextSpan::New() { + return eeNew( UITextSpan, () ); +} + +UITextSpan* UITextSpan::NewWithTag( const std::string& tag ) { + return eeNew( UITextSpan, ( tag ) ); +} + +UITextSpan::UITextSpan( const std::string& tag ) : UIWidget( tag ) { + mFlags |= UI_VALIGN_CENTER | UI_HALIGN_LEFT | UI_LOADS_ITS_CHILDREN; + + UITheme* theme = getUISceneNode()->getUIThemeManager()->getDefaultTheme(); + + if ( NULL != theme && NULL != theme->getDefaultFont() ) { + mFontStyleConfig.Font = theme->getDefaultFont(); + } else if ( NULL != getUISceneNode()->getUIThemeManager()->getDefaultFont() ) { + mFontStyleConfig.Font = getUISceneNode()->getUIThemeManager()->getDefaultFont(); + } + + if ( NULL != theme ) { + mFontStyleConfig.CharacterSize = theme->getDefaultFontSize(); + } else { + mFontStyleConfig.CharacterSize = + getUISceneNode()->getUIThemeManager()->getDefaultFontSize(); + } +} + +UITextSpan::~UITextSpan() {} + +Uint32 UITextSpan::getType() const { + return UI_TYPE_TEXTSPAN; +} + +bool UITextSpan::isType( const Uint32& type ) const { + return UITextSpan::getType() == type ? true : UIWidget::isType( type ); +} + +void UITextSpan::draw() { + // Skip native generic rendering because it will be drawn by UIRichText +} + +bool UITextSpan::applyProperty( const StyleSheetProperty& attribute ) { + if ( !checkPropertyDefinition( attribute ) ) + return false; + + switch ( attribute.getPropertyDefinition()->getPropertyId() ) { + case PropertyId::Text: + setText( getTranslatorString( attribute.value() ) ); + break; + case PropertyId::Color: + setFontColor( attribute.asColor() ); + break; + case PropertyId::TextShadowColor: + setFontShadowColor( attribute.asColor() ); + break; + case PropertyId::TextShadowOffset: + setFontShadowOffset( attribute.asVector2f() ); + break; + case PropertyId::FontFamily: { + Graphics::Font* font = + Graphics::FontManager::instance()->getByName( attribute.value() ); + if ( NULL != font && font->loaded() ) { + setFont( font ); + } + break; + } + case PropertyId::FontSize: + setFontSize( lengthFromValue( attribute ) ); + break; + case PropertyId::FontStyle: + setFontStyle( attribute.asFontStyle() ); + break; + case PropertyId::TextStrokeWidth: + setOutlineThickness( lengthFromValue( attribute ) ); + break; + case PropertyId::TextStrokeColor: + setOutlineColor( attribute.asColor() ); + break; + default: + return UIWidget::applyProperty( attribute ); + } + + return true; +} + +std::string UITextSpan::getPropertyString( const PropertyDefinition* propertyDef, + const Uint32& propertyIndex ) const { + if ( NULL == propertyDef ) + return ""; + + switch ( propertyDef->getPropertyId() ) { + case PropertyId::Text: + return getText().toUtf8(); + case PropertyId::FontFamily: + return NULL != getFont() ? getFont()->getName() : ""; + case PropertyId::FontSize: + return String::format( "%dpx", getFontSize() ); + case PropertyId::FontStyle: + return Graphics::Text::styleFlagToString( getFontStyle() ); + case PropertyId::Color: + return getFontColor().toHexString(); + case PropertyId::TextShadowColor: + return getFontShadowColor().toHexString(); + case PropertyId::TextShadowOffset: + return String::fromFloat( getFontShadowOffset().x ) + " " + + String::fromFloat( getFontShadowOffset().y ); + case PropertyId::TextStrokeWidth: + return String::fromFloat( PixelDensity::dpToPx( getOutlineThickness() ), "px" ); + case PropertyId::TextStrokeColor: + return getOutlineColor().toHexString(); + default: + return UIWidget::getPropertyString( propertyDef, propertyIndex ); + } +} + +std::vector UITextSpan::getPropertiesImplemented() const { + auto props = UIWidget::getPropertiesImplemented(); + auto local = { PropertyId::Text, PropertyId::FontFamily, + PropertyId::FontSize, PropertyId::FontStyle, + PropertyId::Color, PropertyId::TextShadowColor, + PropertyId::TextShadowOffset, PropertyId::TextStrokeWidth, + PropertyId::TextStrokeColor }; + props.insert( props.end(), local.begin(), local.end() ); + return props; +} + +const String& UITextSpan::getText() const { + return mText; +} + +UITextSpan* UITextSpan::setText( const String& text ) { + if ( mText != text ) { + mText = text; + onTextChanged(); + notifyLayoutAttrChange(); + } + return this; +} + +const UIFontStyleConfig& UITextSpan::getFontStyleConfig() const { + return mFontStyleConfig; +} + +void UITextSpan::setFontStyleConfig( const UIFontStyleConfig& fontStyleConfig ) { + mFontStyleConfig = fontStyleConfig; + onFontStyleChanged(); + onFontChanged(); + notifyLayoutAttrChange(); +} + +Graphics::Font* UITextSpan::getFont() const { + return mFontStyleConfig.getFont(); +} + +UITextSpan* UITextSpan::setFont( Graphics::Font* font ) { + if ( mFontStyleConfig.Font != font ) { + mFontStyleConfig.Font = font; + onFontChanged(); + notifyLayoutAttrChange(); + } + return this; +} + +Uint32 UITextSpan::getFontSize() const { + return mFontStyleConfig.getFontCharacterSize(); +} + +UITextSpan* UITextSpan::setFontSize( const Uint32& characterSize ) { + if ( mFontStyleConfig.CharacterSize != characterSize ) { + mFontStyleConfig.CharacterSize = characterSize; + onFontStyleChanged(); + notifyLayoutAttrChange(); + } + return this; +} + +const Uint32& UITextSpan::getFontStyle() const { + return mFontStyleConfig.getFontStyle(); +} + +UITextSpan* UITextSpan::setFontStyle( const Uint32& fontStyle ) { + if ( mFontStyleConfig.Style != fontStyle ) { + mFontStyleConfig.Style = fontStyle; + onFontStyleChanged(); + notifyLayoutAttrChange(); + } + return this; +} + +const Float& UITextSpan::getOutlineThickness() const { + return mFontStyleConfig.getOutlineThickness(); +} + +UITextSpan* UITextSpan::setOutlineThickness( const Float& outlineThickness ) { + if ( mFontStyleConfig.OutlineThickness != outlineThickness ) { + mFontStyleConfig.OutlineThickness = outlineThickness; + onFontStyleChanged(); + notifyLayoutAttrChange(); + } + return this; +} + +const Color& UITextSpan::getOutlineColor() const { + return mFontStyleConfig.getOutlineColor(); +} + +UITextSpan* UITextSpan::setOutlineColor( const Color& outlineColor ) { + if ( mFontStyleConfig.OutlineColor != outlineColor ) { + mFontStyleConfig.OutlineColor = outlineColor; + onFontStyleChanged(); + } + return this; +} + +const Color& UITextSpan::getFontColor() const { + return mFontStyleConfig.getFontColor(); +} + +UITextSpan* UITextSpan::setFontColor( const Color& color ) { + if ( mFontStyleConfig.FontColor != color ) { + mFontStyleConfig.FontColor = color; + onFontStyleChanged(); + } + return this; +} + +const Color& UITextSpan::getFontShadowColor() const { + return mFontStyleConfig.getFontShadowColor(); +} + +UITextSpan* UITextSpan::setFontShadowColor( const Color& color ) { + if ( mFontStyleConfig.ShadowColor != color ) { + mFontStyleConfig.ShadowColor = color; + if ( color != Color::Transparent ) + mFontStyleConfig.Style |= Graphics::Text::Shadow; + else + mFontStyleConfig.Style &= ~Graphics::Text::Shadow; + onFontStyleChanged(); + notifyLayoutAttrChange(); + } + return this; +} + +const Vector2f& UITextSpan::getFontShadowOffset() const { + return mFontStyleConfig.getFontShadowOffset(); +} + +UITextSpan* UITextSpan::setFontShadowOffset( const Vector2f& offset ) { + if ( mFontStyleConfig.ShadowOffset != offset ) { + mFontStyleConfig.ShadowOffset = offset; + onFontStyleChanged(); + notifyLayoutAttrChange(); + } + return this; +} + +void UITextSpan::onAlphaChange() { + UIWidget::onAlphaChange(); + notifyLayoutAttrChange(); +} + +void UITextSpan::onFontChanged() { + sendCommonEvent( Event::OnFontChanged ); +} + +void UITextSpan::onFontStyleChanged() { + sendCommonEvent( Event::OnFontStyleChanged ); +} + +void UITextSpan::onTextChanged() { + sendCommonEvent( Event::OnTextChanged ); + sendCommonEvent( Event::OnValueChange ); +} + +void UITextSpan::loadFromXmlNode( const pugi::xml_node& node ) { + beginAttributesTransaction(); + + UIWidget::loadFromXmlNode( node ); + + for ( pugi::xml_node child = node.first_child(); child; child = child.next_sibling() ) { + if ( child.type() == pugi::node_pcdata ) + mText += getTranslatorString( child.value() ); + } + + endAttributesTransaction(); +} + +}} // namespace EE::UI diff --git a/src/eepp/ui/uiwidget.cpp b/src/eepp/ui/uiwidget.cpp index a957cf897..02e1f0da2 100644 --- a/src/eepp/ui/uiwidget.cpp +++ b/src/eepp/ui/uiwidget.cpp @@ -2024,6 +2024,10 @@ void UIWidget::loadFromXmlNode( const pugi::xml_node& node ) { endAttributesTransaction(); } +bool UIWidget::loadsItsChildren() const { + return ( mFlags & UI_LOADS_ITS_CHILDREN ) != 0; +} + std::string UIWidget::getLayoutWidthPolicyString() const { SizePolicy rules = getLayoutWidthPolicy(); diff --git a/src/eepp/ui/uiwidgetcreator.cpp b/src/eepp/ui/uiwidgetcreator.cpp index 84b9ac81c..0c5e280d8 100644 --- a/src/eepp/ui/uiwidgetcreator.cpp +++ b/src/eepp/ui/uiwidgetcreator.cpp @@ -42,6 +42,8 @@ #include #include #include +#include +#include namespace EE { namespace UI { @@ -106,6 +108,8 @@ void UIWidgetCreator::createBaseWidgetList() { registeredWidget["nodelink"] = UINodeLink::New; registeredWidget["textureviewer"] = Tools::UITextureViewer::New; registeredWidget["imageviewer"] = Tools::UIImageViewer::New; + registeredWidget["richtext"] = UIRichText::New; + registeredWidget["textspan"] = UITextSpan::New; registeredWidget["hbox"] = UILinearLayout::NewHorizontal; registeredWidget["vbox"] = UILinearLayout::NewVertical; @@ -122,6 +126,8 @@ void UIWidgetCreator::createBaseWidgetList() { registeredWidget["tooltip"] = UITooltip::New; registeredWidget["tv"] = UITextView::New; registeredWidget["a"] = UIAnchor::New; + registeredWidget["span"] = UITextSpan::New; + registeredWidget["p"] = UIRichText::New; sBaseListCreated = true; } diff --git a/src/examples/ui_richtext/ui_richtext.cpp b/src/examples/ui_richtext/ui_richtext.cpp new file mode 100644 index 000000000..c49a5aed4 --- /dev/null +++ b/src/examples/ui_richtext/ui_richtext.cpp @@ -0,0 +1,28 @@ +#include + +EE_MAIN_FUNC int main( int, char** ) { + UIApplication app( { 800, 600, "eepp - UIRichText Example" } ); + app.getUI()->loadLayoutFromString( R"xml( + + Welcome to the UIRichText example! + This component supports styled text, + shadows, + and outlines using HTML-like tags. + + + We can also mix contents with more text! + + + )xml" ); + return app.run(); +} diff --git a/src/tests/unit_tests/richtext.cpp b/src/tests/unit_tests/richtext.cpp index 1150ba39d..2e419f381 100644 --- a/src/tests/unit_tests/richtext.cpp +++ b/src/tests/unit_tests/richtext.cpp @@ -8,6 +8,10 @@ #include #include #include +#include +#include +#include +#include #include using namespace EE; @@ -235,3 +239,52 @@ UTEST( RichText, RichTextTest ) { runTest(); } } + +UTEST( UIRichText, IntegrationAndLayoutVerification ) { + Engine::instance()->createWindow( WindowSettings( 800, 600, "RichText Test", + WindowStyle::Default, WindowBackend::Default, + 32, {}, 1, false, true ) ); + FileSystem::changeWorkingDirectory( Sys::getProcessPath() ); + + FontTrueType* font = FontTrueType::New( "NotoSans-Regular" ); + font->loadFromFile( "../assets/fonts/NotoSans-Regular.ttf" ); + + ASSERT_TRUE( font->loaded() ); + FontFamily::loadFromRegular( font ); + + UI::UISceneNode* sceneNode = UI::UISceneNode::New(); + UI::UIThemeManager* themeManager = sceneNode->getUIThemeManager(); + themeManager->setDefaultFont( font ); + + String xml = R"xml( + Hello RedWorld + )xml"; + + sceneNode->loadLayoutFromString( xml ); + + UI::UIRichText* rt = sceneNode->find( "rt" ); + ASSERT_TRUE( rt != nullptr ); + + // force layout + sceneNode->update( Time::Zero ); + + auto* graphicsRt = rt->getRichText(); + const auto& blocks = graphicsRt->getBlocks(); + + ASSERT_EQ( blocks.size(), (size_t)4 ); + EXPECT_EQ( blocks[1].type, Graphics::RichText::BlockType::Text ); + EXPECT_TRUE( blocks[1].text->getFillColor() == Color::fromString( "#FF0000" ) ); + + EXPECT_EQ( blocks[2].type, Graphics::RichText::BlockType::CustomSize ); + EXPECT_EQ( blocks[2].customSize.getWidth(), PixelDensity::dpToPx( 50 ) ); + + UI::UIWidget* placeholder = rt->find( "placeholder" ); + ASSERT_TRUE( placeholder != nullptr ); + + Vector2f pos = placeholder->getPixelsPosition(); + Float expectedX = blocks[0].text->getTextWidth() + blocks[1].text->getTextWidth(); + EXPECT_NEAR( pos.x, expectedX, 2.0f ); + + eeDelete( sceneNode ); + Engine::destroySingleton(); +}