diff --git a/include/eepp/scene/node.hpp b/include/eepp/scene/node.hpp index 020fbe14b..005452cdc 100644 --- a/include/eepp/scene/node.hpp +++ b/include/eepp/scene/node.hpp @@ -79,7 +79,8 @@ enum NodeFlags { NODE_FLAG_LOADING = ( 1 << 27 ), NODE_FLAG_CLOSING_CHILDREN = ( 1 << 28 ), NODE_FLAG_DISABLE_CLICK_FOCUS = ( 1 << 29 ), - NODE_FLAG_FREE_USE = ( 1 << 30 ) + NODE_FLAG_TEXTNODE = ( 1 << 30 ), + NODE_FLAG_FREE_USE = ( 1 << 31 ) }; /** @@ -209,6 +210,9 @@ class EE_API Node : public Transformable { */ virtual bool isType( const Uint32& type ) const; + /** @return True if this node is a UITextNode, false otherwise. */ + bool isTextNode() const; + /** * @brief Posts a message to this node and its ancestors. * diff --git a/include/eepp/ui/uihelper.hpp b/include/eepp/ui/uihelper.hpp index 1be2a62ad..fecbd2837 100644 --- a/include/eepp/ui/uihelper.hpp +++ b/include/eepp/ui/uihelper.hpp @@ -131,6 +131,7 @@ enum UINodeType { UI_TYPE_HTML_LIST_ITEM, UI_TYPE_HTML_IMAGE, UI_TYPE_SVG, + UI_TYPE_TEXTNODE, UI_TYPE_MODULES = 10000, UI_TYPE_TERMINAL = 10001, UI_TYPE_USER = 200000, diff --git a/include/eepp/ui/uitextnode.hpp b/include/eepp/ui/uitextnode.hpp new file mode 100644 index 000000000..eb3763e14 --- /dev/null +++ b/include/eepp/ui/uitextnode.hpp @@ -0,0 +1,35 @@ +#ifndef EE_UI_UITEXTNODE_HPP +#define EE_UI_UITEXTNODE_HPP + +#include + +namespace EE { namespace UI { + +class EE_API UITextNode : public UIWidget { + public: + static UITextNode* New(); + + UITextNode(); + + virtual ~UITextNode(); + + virtual Uint32 getType() const; + + virtual bool isType( const Uint32& type ) const; + + virtual void draw(); + + virtual std::string getPropertyString( const PropertyDefinition* propertyDef, + const Uint32& propertyIndex = 0 ) const; + + const String& getText() const; + + void setText( const String& text ); + + protected: + String mText; +}; + +}} // namespace EE::UI + +#endif \ No newline at end of file diff --git a/include/eepp/ui/uiwidget.hpp b/include/eepp/ui/uiwidget.hpp index 5947347e3..910be3bb3 100644 --- a/include/eepp/ui/uiwidget.hpp +++ b/include/eepp/ui/uiwidget.hpp @@ -800,6 +800,21 @@ class EE_API UIWidget : public UINode { */ std::vector getStyleSheetPseudoClassesStrings() const; + /** @return True if the widget is not a text node. */ + bool isWidgetElement() const; + + /** @return The index of this element among its sibling elements. */ + Uint32 getElementIndex() const; + + /** @return The index of this element among its sibling elements of the same type. */ + Uint32 getElementOfTypeIndex() const; + + /** @return The number of child elements. */ + Uint32 getChildElementCount() const; + + /** @return The number of child elements of the specified type. */ + Uint32 getChildElementOfTypeCount( const Uint32& type ) const; + /** * @brief Resets all CSS classes and removes them. * diff --git a/src/eepp/scene/node.cpp b/src/eepp/scene/node.cpp index 70fbb7be6..6b0f94c42 100644 --- a/src/eepp/scene/node.cpp +++ b/src/eepp/scene/node.cpp @@ -1064,6 +1064,10 @@ bool Node::isWidget() const { return 0 != ( mNodeFlags & NODE_FLAG_WIDGET ); } +bool Node::isTextNode() const { + return 0 != ( mNodeFlags & NODE_FLAG_TEXTNODE ); +} + bool Node::isWindow() const { return 0 != ( mNodeFlags & NODE_FLAG_WINDOW ); } diff --git a/src/eepp/ui/blocklayouter.cpp b/src/eepp/ui/blocklayouter.cpp index 7f6436643..bae9a6309 100644 --- a/src/eepp/ui/blocklayouter.cpp +++ b/src/eepp/ui/blocklayouter.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include namespace EE { namespace UI { @@ -131,11 +132,26 @@ void BlockLayouter::positionRichTextChildren( Graphics::RichText* rt ) { Int64 curCharIdx = 0; - auto processWidget = [&]( UIWidget* widget, auto& processWidgetRef ) -> Rectf { + auto processNode = [&]( Node* node, auto& processNodeRef ) -> Rectf { constexpr Float maxF = std::numeric_limits::max(); constexpr Float lowF = std::numeric_limits::lowest(); Rectf bounds( maxF, maxF, lowF, lowF ); + // UITextNode is a logical marker; its text is rendered by the + // RichText engine — just advance the character index and return + // empty bounds so it does not affect any widget's geometry. + if ( node->isTextNode() ) { + curCharIdx += static_cast( node )->getText().length(); + return bounds; + } + + if ( !node->isWidget() ) + return bounds; + + UIWidget* widget = node->asType(); + + // Accumulate ancestor positions so the widget can be placed + // relative to the container (mContainer). Vector2f offset; Node* p = widget->getParent(); while ( p && p != mContainer ) { @@ -181,12 +197,14 @@ void BlockLayouter::positionRichTextChildren( Graphics::RichText* rt ) { } } + // Recurse into children. UITextNode children advance + // curCharIdx but contribute no geometry (they are logical + // markers only). Widget children get their own position + // and hit-boxes. Node* spanChild = widget->getFirstChild(); while ( spanChild != NULL ) { - if ( spanChild->isWidget() ) { - bounds.expand( - processWidgetRef( spanChild->asType(), processWidgetRef ) ); - } + if ( spanChild->isWidget() ) + bounds.expand( processNodeRef( spanChild, processNodeRef ) ); spanChild = spanChild->getNextNode(); } @@ -241,12 +259,10 @@ void BlockLayouter::positionRichTextChildren( Graphics::RichText* rt ) { child = mContainer->getFirstChild(); while ( NULL != child ) { - if ( child->isWidget() ) { - bool isOutOfFlow = child->isType( UI_TYPE_HTML_WIDGET ) && - child->asType()->isOutOfFlow(); - if ( !isOutOfFlow ) - processWidget( child->asType(), processWidget ); - } + bool isOutOfFlow = + child->isType( UI_TYPE_HTML_WIDGET ) && child->asType()->isOutOfFlow(); + if ( !isOutOfFlow ) + processNode( child, processNode ); child = child->getNextNode(); } } diff --git a/src/eepp/ui/css/stylesheetspecification.cpp b/src/eepp/ui/css/stylesheetspecification.cpp index 837f25f02..e9f85813a 100644 --- a/src/eepp/ui/css/stylesheetspecification.cpp +++ b/src/eepp/ui/css/stylesheetspecification.cpp @@ -553,7 +553,7 @@ void StyleSheetSpecification::registerDefaultNodeSelectors() { }; mNodeSelectors["first-child"] = []( const UIWidget* node, int, int, const FunctionString& ) -> bool { - return NULL != node->getParent() && node->getParent()->getFirstChild() == node; + return NULL != node->getParent() && node->getElementIndex() == 0; }; mNodeSelectors["enabled"] = []( const UIWidget* node, int, int, const FunctionString& ) -> bool { return node->isEnabled(); }; @@ -561,69 +561,69 @@ void StyleSheetSpecification::registerDefaultNodeSelectors() { const FunctionString& ) -> bool { return !node->isEnabled(); }; mNodeSelectors["first-of-type"] = []( const UIWidget* node, int, int, const FunctionString& ) -> bool { - Node* child = NULL != node->getParent() ? node->getParent()->getFirstChild() : NULL; - Uint32 type = node->getType(); - while ( NULL != child ) { - if ( type == child->getType() ) { - return child == node; - } - child = child->getNextNode(); - }; - return false; + return NULL != node->getParent() && node->getElementOfTypeIndex() == 0; }; mNodeSelectors["last-child"] = []( const UIWidget* node, int, int, const FunctionString& ) -> bool { - return NULL != node->getParent() && node->getParent()->getLastChild() == node; + if ( NULL == node->getParent() || !node->getParent()->isWidget() ) + return false; + Node* child = node->getParent()->getLastChild(); + while ( NULL != child ) { + if ( child->isWidget() && !static_cast( child )->isTextNode() ) + return child == node; + child = child->getPrevNode(); + } + return false; }; mNodeSelectors["last-of-type"] = []( const UIWidget* node, int, int, const FunctionString& ) -> bool { - Node* child = NULL != node->getParent() ? node->getParent()->getLastChild() : NULL; + if ( NULL == node->getParent() || !node->getParent()->isWidget() ) + return false; Uint32 type = node->getType(); + Node* child = node->getParent()->getLastChild(); while ( NULL != child ) { - if ( type == child->getType() ) { + if ( child->getType() == type && child->isWidget() && + !static_cast( child )->isTextNode() ) return child == node; - } child = child->getPrevNode(); - }; + } return false; }; mNodeSelectors["only-child"] = []( const UIWidget* node, int, int, const FunctionString& ) -> bool { - return NULL != node->getParent() && node->getParent()->getChildCount() == 1; + return NULL != node->getParent() && node->getParent()->isWidget() && + static_cast( node->getParent() )->getChildElementCount() == 1; }; mNodeSelectors["only-of-type"] = []( const UIWidget* node, int, int, const FunctionString& ) -> bool { - Node* child = NULL != node->getParent() ? node->getParent()->getFirstChild() : NULL; - Uint32 type = node->getType(); - Uint32 typeCount = 0; - while ( NULL != child ) { - if ( child->getType() == type ) { - typeCount++; - } - if ( typeCount > 1 ) - return false; - child = child->getNextNode(); - }; - return typeCount == 1; + return NULL != node->getParent() && node->getParent()->isWidget() && + static_cast( node->getParent() ) + ->getChildElementOfTypeCount( node->getType() ) == 1; }; mNodeSelectors["nth-child"] = []( const UIWidget* node, int a, int b, const FunctionString& ) -> bool { - return isNth( a, b, node->getNodeIndex() + 1 ); + return isNth( a, b, node->getElementIndex() + 1 ); }; mNodeSelectors["nth-last-child"] = []( const UIWidget* node, int a, int b, const FunctionString& ) -> bool { - return isNth( a, b, node->getChildCount() - node->getNodeIndex() ); + return node->getParent() != NULL && node->getParent()->isWidget() + ? isNth( + a, b, + static_cast( node->getParent() )->getChildElementCount() - + node->getElementIndex() ) + : false; }; mNodeSelectors["nth-of-type"] = []( const UIWidget* node, int a, int b, const FunctionString& ) -> bool { - return isNth( a, b, node->getNodeOfTypeIndex() + 1 ); + return isNth( a, b, node->getElementOfTypeIndex() + 1 ); }; mNodeSelectors["nth-last-of-type"] = []( const UIWidget* node, int a, int b, const FunctionString& ) -> bool { - return node->getParent() != NULL + return node->getParent() != NULL && node->getParent()->isWidget() ? isNth( a, b, - node->getParent()->getChildOfTypeCount( node->getType() ) - - node->getNodeOfTypeIndex() ) + static_cast( node->getParent() ) + ->getChildElementOfTypeCount( node->getType() ) - + node->getElementOfTypeIndex() ) : false; }; mNodeSelectors["checked"] = []( const UIWidget* node, int, int, diff --git a/src/eepp/ui/uirichtext.cpp b/src/eepp/ui/uirichtext.cpp index ccec38811..3a3ebac92 100644 --- a/src/eepp/ui/uirichtext.cpp +++ b/src/eepp/ui/uirichtext.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -549,9 +550,8 @@ void UIRichText::loadFromXmlNode( const pugi::xml_node& node ) { } else if ( child.type() == pugi::node_pcdata ) { String text = Tools::HTMLFormatter::collapseXmlWhitespace( child.value(), child ); if ( !text.empty() ) { - UITextSpan* span = UITextSpan::New(); + UITextNode* span = UITextNode::New(); span->setParent( this ); - span->setInheritedStyle( mRichText.getFontStyleConfig() ); span->setText( text ); } } @@ -596,8 +596,7 @@ void UIRichText::rebuildRichText( UILayout* container, RichText& richText, Intri richText.clear(); Float maxWidth = 0; if ( container->getLayoutWidthPolicy() == SizePolicy::WrapContent ) { - maxWidth = container->getMatchParentWidth() - - container->getPixelsContentOffset().Left - + maxWidth = container->getMatchParentWidth() - container->getPixelsContentOffset().Left - container->getPixelsContentOffset().Right; } else { maxWidth = container->getPixelsSize().getWidth() - @@ -634,7 +633,29 @@ void UIRichText::rebuildRichText( UILayout* container, RichText& richText, Intri } } - auto processWidget = [&]( UIWidget* widget, auto& processWidgetRef ) -> void { + auto processNode = [&]( Node* node, auto& processNodeRef ) -> void { + if ( node->isTextNode() ) { + UITextNode* textNode = static_cast( node ); + if ( !textNode->getText().empty() ) { + FontStyleConfig style; + if ( node->getParent()->isType( UI_TYPE_TEXTSPAN ) ) { + style = node->getParent()->asType()->getFontStyleConfig(); + } else if ( node->getParent()->isType( UI_TYPE_RICHTEXT ) ) { + style = + node->getParent()->asType()->getRichText().getFontStyleConfig(); + } else { + style = richText.getFontStyleConfig(); + } + richText.addSpan( textNode->getText(), style ); + } + return; + } + + if ( !node->isWidget() ) + return; + + UIWidget* widget = node->asType(); + if ( widget->isType( UI_TYPE_HTML_WIDGET ) && widget->asType()->isMergeable() ) { UITextSpan* span = widget->asType(); @@ -648,7 +669,7 @@ void UIRichText::rebuildRichText( UILayout* container, RichText& richText, Intri bool isOutOfFlow = spanChild->isType( UI_TYPE_HTML_WIDGET ) && spanChild->asType()->isOutOfFlow(); if ( !isOutOfFlow ) - processWidgetRef( spanChild->asType(), processWidgetRef ); + processNodeRef( spanChild, processNodeRef ); spanChild = spanChild->getNextNode(); } } else if ( widget->isType( UI_TYPE_BR ) ) { @@ -712,7 +733,7 @@ void UIRichText::rebuildRichText( UILayout* container, RichText& richText, Intri bool isOutOfFlow = child->isType( UI_TYPE_HTML_WIDGET ) && child->asType()->isOutOfFlow(); if ( !isOutOfFlow ) - processWidget( child->asType(), processWidget ); + processNode( child, processNode ); child = child->getNextNode(); } } diff --git a/src/eepp/ui/uitextnode.cpp b/src/eepp/ui/uitextnode.cpp new file mode 100644 index 000000000..296a99887 --- /dev/null +++ b/src/eepp/ui/uitextnode.cpp @@ -0,0 +1,56 @@ +#include +#include + +namespace EE { namespace UI { + +UITextNode* UITextNode::New() { + return eeNew( UITextNode, () ); +} + +UITextNode::UITextNode() : UIWidget( "textnode" ) { + mNodeFlags |= NODE_FLAG_TEXTNODE; + mFlags |= UI_HTML_ELEMENT; +} + +UITextNode::~UITextNode() {} + +Uint32 UITextNode::getType() const { + return UI_TYPE_TEXTNODE; +} + +bool UITextNode::isType( const Uint32& type ) const { + return UITextNode::getType() == type ? true : UIWidget::isType( type ); +} + +void UITextNode::draw() { + // Text nodes do not draw themselves; their parent handles rendering +} + +std::string UITextNode::getPropertyString( const PropertyDefinition* propertyDef, + const Uint32& propertyIndex ) const { + if ( NULL == propertyDef ) + return ""; + + const StyleSheetProperty* prop = getUIStyle()->getProperty( propertyDef->getPropertyId() ); + if ( prop ) + return prop->value(); + + if ( propertyDef->isInherited() && getParent() && getParent()->isWidget() ) + return static_cast( getParent() ) + ->getPropertyString( propertyDef, propertyIndex ); + + return UIWidget::getPropertyString( propertyDef, propertyIndex ); +} + +const String& UITextNode::getText() const { + return mText; +} + +void UITextNode::setText( const String& text ) { + if ( mText != text ) { + mText = text; + notifyLayoutAttrChange(); + } +} + +}} // namespace EE::UI diff --git a/src/eepp/ui/uitextspan.cpp b/src/eepp/ui/uitextspan.cpp index 6fc1cb46b..fc16cbc67 100644 --- a/src/eepp/ui/uitextspan.cpp +++ b/src/eepp/ui/uitextspan.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include @@ -411,9 +412,8 @@ void UITextSpan::loadFromXmlNode( const pugi::xml_node& node ) { } else if ( child.type() == pugi::node_pcdata ) { String text = Tools::HTMLFormatter::collapseXmlWhitespace( child.value(), child ); if ( !text.empty() ) { - UITextSpan* span = UITextSpan::New(); + UITextNode* span = UITextNode::New(); span->setParent( this ); - span->setInheritedStyle( mRichText.getFontStyleConfig() ); span->setText( text ); } } diff --git a/src/eepp/ui/uiwidget.cpp b/src/eepp/ui/uiwidget.cpp index 2164df19a..f9597abfb 100644 --- a/src/eepp/ui/uiwidget.cpp +++ b/src/eepp/ui/uiwidget.cpp @@ -997,6 +997,64 @@ std::vector UIWidget::getStyleSheetPseudoClassesStrings() const { return StyleSheetSelectorRule::fromPseudoClass( mPseudoClasses ); } +bool UIWidget::isWidgetElement() const { + return !isTextNode(); +} + +Uint32 UIWidget::getElementIndex() const { + Uint32 index = 0; + if ( NULL != mParentNode ) { + Node* parentChild = mParentNode->getFirstChild(); + while ( parentChild != NULL ) { + if ( parentChild == this ) + return index; + if ( parentChild->isWidget() && !parentChild->isTextNode() ) + index++; + parentChild = parentChild->getNextNode(); + } + } + return 0; +} + +Uint32 UIWidget::getElementOfTypeIndex() const { + Uint32 index = 0; + if ( NULL != mParentNode ) { + Node* parentChild = mParentNode->getFirstChild(); + Uint32 type = getType(); + while ( parentChild != NULL ) { + if ( parentChild == this ) + return index; + if ( parentChild->getType() == type && parentChild->isWidget() && + !parentChild->isTextNode() ) + index++; + parentChild = parentChild->getNextNode(); + } + } + return 0; +} + +Uint32 UIWidget::getChildElementCount() const { + Uint32 count = 0; + Node* child = mChild; + while ( NULL != child ) { + if ( child->isWidget() && !child->isTextNode() ) + count++; + child = child->getNextNode(); + } + return count; +} + +Uint32 UIWidget::getChildElementOfTypeCount( const Uint32& type ) const { + Uint32 count = 0; + Node* child = mChild; + while ( NULL != child ) { + if ( child->getType() == type && child->isWidget() && !child->isTextNode() ) + count++; + child = child->getNextNode(); + } + return count; +} + void UIWidget::updatePseudoClasses() { mPseudoClasses = 0; diff --git a/src/tests/unit_tests/richtext.cpp b/src/tests/unit_tests/richtext.cpp index bdc39dacf..1a2f98470 100644 --- a/src/tests/unit_tests/richtext.cpp +++ b/src/tests/unit_tests/richtext.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include @@ -666,8 +667,8 @@ UTEST( UIRichText, WhitespaceCollapseCodeTest ) { bool foundDotSpace = false; Node* child = rt->getFirstChild(); while ( child ) { - if ( child->isWidget() && child->isType( UI_TYPE_TEXTSPAN ) ) { - UI::UITextSpan* span = static_cast( child ); + if ( child->isTextNode() ) { + UI::UITextNode* span = static_cast( child ); if ( span->getText() == ". " ) { foundDotSpace = true; } diff --git a/src/tests/unit_tests/uicss_inheritance_test.cpp b/src/tests/unit_tests/uicss_inheritance_test.cpp index e241eac54..be710d434 100644 --- a/src/tests/unit_tests/uicss_inheritance_test.cpp +++ b/src/tests/unit_tests/uicss_inheritance_test.cpp @@ -94,7 +94,10 @@ UTEST( CSSInheritance, ComputedFontSize ) { UIWidget* childWidget = child->asType(); std::string pxStr = childWidget->getPropertyString( StyleSheetSpecification::instance()->getProperty( PropertyId::FontSize ) ); - EXPECT_NEAR( 32u * scale, childWidget->asType()->getFontSize(), 1.f ); + EXPECT_FALSE( pxStr.empty() ); + EXPECT_NEAR( 32u * scale, + childWidget->lengthFromValue( StyleSheetProperty( "font-size", pxStr ) ), + 1.f ); } } diff --git a/src/tests/unit_tests/uitextnode_test.cpp b/src/tests/unit_tests/uitextnode_test.cpp new file mode 100644 index 000000000..5029048c0 --- /dev/null +++ b/src/tests/unit_tests/uitextnode_test.cpp @@ -0,0 +1,1091 @@ +#include "utest.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace EE; +using namespace EE::Graphics; +using namespace EE::Window; +using namespace EE::Scene; +using namespace EE::UI; +using namespace EE::UI::CSS; + +// Helper: create a basic scene for RichText tests +static UI::UISceneNode* createRichTextScene() { + Engine::instance()->createWindow( WindowSettings( 800, 600, "UITextNode 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" ); + if ( !font->loaded() ) { + Engine::destroySingleton(); + return nullptr; + } + FontFamily::loadFromRegular( font ); + + UI::UISceneNode* sceneNode = UI::UISceneNode::New(); + sceneNode->getUIThemeManager()->setDefaultFont( font ); + return sceneNode; +} + +static void destroyRichTextScene( UI::UISceneNode* sceneNode ) { + eeDelete( sceneNode ); + Engine::destroySingleton(); +} + +// ============================================================ +// Suite: UITextNode_Basics — no scene needed +// ============================================================ + +UTEST( UITextNode_Basics, CreationAndType ) { + UITextNode* textNode = UITextNode::New(); + + EXPECT_TRUE( textNode->isTextNode() ); + EXPECT_TRUE( textNode->getType() == UI_TYPE_TEXTNODE ); + EXPECT_TRUE( textNode->isType( UI_TYPE_TEXTNODE ) ); + EXPECT_TRUE( textNode->isWidget() ); + EXPECT_TRUE( textNode->isType( UI_TYPE_WIDGET ) ); + EXPECT_FALSE( textNode->isType( UI_TYPE_TEXTSPAN ) ); + EXPECT_FALSE( textNode->isWidgetElement() ); + + eeDelete( textNode ); +} + +UTEST( UITextNode_Basics, TextGetSet ) { + UITextNode* textNode = UITextNode::New(); + + EXPECT_TRUE( textNode->getText().empty() ); + textNode->setText( "Hello World" ); + EXPECT_STRINGEQ( textNode->getText(), "Hello World" ); + textNode->setText( "New Text" ); + EXPECT_STRINGEQ( textNode->getText(), "New Text" ); + textNode->setText( "" ); + EXPECT_TRUE( textNode->getText().empty() ); + + eeDelete( textNode ); +} + +UTEST( UITextNode_Basics, NodeFlagIsSet ) { + UITextNode* textNode = UITextNode::New(); + EXPECT_TRUE( textNode->getNodeFlags() & NODE_FLAG_TEXTNODE ); + eeDelete( textNode ); +} + +UTEST( UITextNode_Basics, IsTypeDelegatesToParent ) { + UITextNode* tn = UITextNode::New(); + + EXPECT_TRUE( tn->isType( UI_TYPE_WIDGET ) ); + EXPECT_TRUE( tn->isType( UI_TYPE_UINODE ) ); + EXPECT_FALSE( tn->isType( UI_TYPE_RICHTEXT ) ); + EXPECT_FALSE( tn->isType( UI_TYPE_TEXTSPAN ) ); + + eeDelete( tn ); +} + +// ============================================================ +// Suite: UITextNode_ElementCounting — via loaded XML +// ============================================================ + +UTEST( UITextNode_ElementCounting, GetElementIndexMixedChildren ) { + auto sceneNode = createRichTextScene(); + ASSERT_TRUE( sceneNode != nullptr ); + + // Build a tree: RichText → [TextNode "before", Span(B), TextNode "between", Span(D)] + // B is element index 0, D is element index 1 + String xml = R"xml( + + before B between D + + )xml"; + + sceneNode->loadLayoutFromString( xml ); + sceneNode->update( Time::Zero ); + + UITextSpan* elem0 = sceneNode->find( "elem0" ); + UITextSpan* elem1 = sceneNode->find( "elem1" ); + ASSERT_TRUE( elem0 != nullptr ); + ASSERT_TRUE( elem1 != nullptr ); + + EXPECT_EQ( elem0->getElementIndex(), 0u ); + EXPECT_EQ( elem1->getElementIndex(), 1u ); + + // Type-specific: both are UITextSpan type + EXPECT_EQ( elem0->getElementOfTypeIndex(), 0u ); + EXPECT_EQ( elem1->getElementOfTypeIndex(), 1u ); + + destroyRichTextScene( sceneNode ); +} + +UTEST( UITextNode_ElementCounting, OnlyTextNodeChildren ) { + auto sceneNode = createRichTextScene(); + ASSERT_TRUE( sceneNode != nullptr ); + + // RichText with only text, no elements → child elements = 0 + String xml = R"xml( + + Hello World + + )xml"; + + sceneNode->loadLayoutFromString( xml ); + sceneNode->update( Time::Zero ); + + UIRichText* rt = sceneNode->find( "rt" ); + ASSERT_TRUE( rt != nullptr ); + + EXPECT_EQ( rt->getChildElementCount(), 0u ); + + destroyRichTextScene( sceneNode ); +} + +// ============================================================ +// Suite: UITextNode_CSSSelectors — via App + XML +// ============================================================ + +UTEST( UITextNode_CSSSelectors, EmptyWithTextNodes ) { + auto sceneNode = createRichTextScene(); + ASSERT_TRUE( sceneNode != nullptr ); + + String xml = R"xml( + + Has text + + + )xml"; + + sceneNode->loadLayoutFromString( xml ); + sceneNode->update( Time::Zero ); + + auto& spec = *StyleSheetSpecification::instance(); + StructuralSelector emptySel = spec.getStructuralSelector( "empty" ); + ASSERT_TRUE( emptySel.selector != nullptr ); + + UIRichText* rtText = sceneNode->find( "rtText" ); + UIRichText* rtEmpty = sceneNode->find( "rtEmpty" ); + ASSERT_TRUE( rtText != nullptr ); + ASSERT_TRUE( rtEmpty != nullptr ); + + // rtText has a text node child → NOT empty + EXPECT_FALSE( emptySel.selector( rtText, 0, 0, FunctionString::parse( "" ) ) ); + // rtEmpty has no children → empty + EXPECT_TRUE( emptySel.selector( rtEmpty, 0, 0, FunctionString::parse( "" ) ) ); + + destroyRichTextScene( sceneNode ); +} + +UTEST( UITextNode_CSSSelectors, FirstChildWithTextNodes ) { + auto sceneNode = createRichTextScene(); + ASSERT_TRUE( sceneNode != nullptr ); + + // RichText children: [TextNode "ignored", Span(0), TextNode "between", Span(1)] + // Span(0) is first element child + String xml = R"xml( + + ignored first between second + + )xml"; + + sceneNode->loadLayoutFromString( xml ); + sceneNode->update( Time::Zero ); + + auto& spec = *StyleSheetSpecification::instance(); + StructuralSelector sel = spec.getStructuralSelector( "first-child" ); + ASSERT_TRUE( sel.selector != nullptr ); + + UITextSpan* elem0 = sceneNode->find( "elem0" ); + UITextSpan* elem1 = sceneNode->find( "elem1" ); + ASSERT_TRUE( elem0 != nullptr ); + ASSERT_TRUE( elem1 != nullptr ); + + EXPECT_TRUE( sel.selector( elem0, 0, 0, FunctionString::parse( "" ) ) ); + EXPECT_FALSE( sel.selector( elem1, 0, 0, FunctionString::parse( "" ) ) ); + + destroyRichTextScene( sceneNode ); +} + +UTEST( UITextNode_CSSSelectors, LastChildWithTextNodes ) { + auto sceneNode = createRichTextScene(); + ASSERT_TRUE( sceneNode != nullptr ); + + // RichText children: [Span(0), TextNode "ignored", Span(1), TextNode "trailing"] + // Span(1) is last element child + String xml = R"xml( + + first ignored last trailing + + )xml"; + + sceneNode->loadLayoutFromString( xml ); + sceneNode->update( Time::Zero ); + + auto& spec = *StyleSheetSpecification::instance(); + StructuralSelector sel = spec.getStructuralSelector( "last-child" ); + ASSERT_TRUE( sel.selector != nullptr ); + + UITextSpan* elem0 = sceneNode->find( "elem0" ); + UITextSpan* elem1 = sceneNode->find( "elem1" ); + ASSERT_TRUE( elem0 != nullptr ); + ASSERT_TRUE( elem1 != nullptr ); + + EXPECT_FALSE( sel.selector( elem0, 0, 0, FunctionString::parse( "" ) ) ); + EXPECT_TRUE( sel.selector( elem1, 0, 0, FunctionString::parse( "" ) ) ); + + destroyRichTextScene( sceneNode ); +} + +UTEST( UITextNode_CSSSelectors, OnlyChildWithTextNodes ) { + auto sceneNode = createRichTextScene(); + ASSERT_TRUE( sceneNode != nullptr ); + + // RichText children: [TextNode, Span] → Span IS only element child + // Text nodes are intentionally invisible to structural selectors + String xml = R"xml( + + ignored only + + )xml"; + + sceneNode->loadLayoutFromString( xml ); + sceneNode->update( Time::Zero ); + + auto& spec = *StyleSheetSpecification::instance(); + StructuralSelector sel = spec.getStructuralSelector( "only-child" ); + ASSERT_TRUE( sel.selector != nullptr ); + + UITextSpan* elem0 = sceneNode->find( "elem0" ); + ASSERT_TRUE( elem0 != nullptr ); + + // Text nodes are invisible → elem0 IS the only element child + EXPECT_TRUE( sel.selector( elem0, 0, 0, FunctionString::parse( "" ) ) ); + + destroyRichTextScene( sceneNode ); +} + +UTEST( UITextNode_CSSSelectors, FirstOfTypeWithTextNodes ) { + auto sceneNode = createRichTextScene(); + ASSERT_TRUE( sceneNode != nullptr ); + + // RichText children: [TextNode, Span(0), TextNode, Span(1), TextNode, br(0)] + // Span(0) is first-of-type for SPAN, br(0) is first-of-type for BR (different getType) + String xml = R"xml( + + a first b second c
+
+ )xml"; + + sceneNode->loadLayoutFromString( xml ); + sceneNode->update( Time::Zero ); + + auto& spec = *StyleSheetSpecification::instance(); + StructuralSelector sel = spec.getStructuralSelector( "first-of-type" ); + ASSERT_TRUE( sel.selector != nullptr ); + + UITextSpan* span0 = sceneNode->find( "span0" ); + UITextSpan* span1 = sceneNode->find( "span1" ); + ASSERT_TRUE( span0 != nullptr ); + ASSERT_TRUE( span1 != nullptr ); + + Node* br0 = sceneNode->find( "br0" ); + ASSERT_TRUE( br0 != nullptr ); + + EXPECT_TRUE( sel.selector( span0, 0, 0, FunctionString::parse( "" ) ) ); + EXPECT_FALSE( sel.selector( span1, 0, 0, FunctionString::parse( "" ) ) ); + EXPECT_TRUE( sel.selector( static_cast( br0 ), 0, 0, FunctionString::parse( "" ) ) ); + + destroyRichTextScene( sceneNode ); +} + +UTEST( UITextNode_CSSSelectors, NthChildWithTextNodes ) { + auto sceneNode = createRichTextScene(); + ASSERT_TRUE( sceneNode != nullptr ); + + String xml = R"xml( + + a 1 b 2 + + )xml"; + + sceneNode->loadLayoutFromString( xml ); + sceneNode->update( Time::Zero ); + + auto& spec = *StyleSheetSpecification::instance(); + + StructuralSelector nth1 = spec.getStructuralSelector( "nth-child(1)" ); + StructuralSelector nth2 = spec.getStructuralSelector( "nth-child(2)" ); + ASSERT_TRUE( nth1.selector != nullptr ); + ASSERT_TRUE( nth2.selector != nullptr ); + + UITextSpan* elem0 = sceneNode->find( "elem0" ); + UITextSpan* elem1 = sceneNode->find( "elem1" ); + ASSERT_TRUE( elem0 != nullptr ); + ASSERT_TRUE( elem1 != nullptr ); + + EXPECT_TRUE( nth1.selector( elem0, nth1.a, nth1.b, nth1.data ) ); + EXPECT_TRUE( nth2.selector( elem1, nth2.a, nth2.b, nth2.data ) ); + + destroyRichTextScene( sceneNode ); +} + +UTEST( UITextNode_CSSSelectors, NthOfTypeWithTextNodes ) { + auto sceneNode = createRichTextScene(); + ASSERT_TRUE( sceneNode != nullptr ); + + // Two spans (same type) + one br (different type) + String xml = R"xml( + + a 1 b 2 c
+
+ )xml"; + + sceneNode->loadLayoutFromString( xml ); + sceneNode->update( Time::Zero ); + + auto& spec = *StyleSheetSpecification::instance(); + StructuralSelector nth1 = spec.getStructuralSelector( "nth-of-type(1)" ); + StructuralSelector nth2 = spec.getStructuralSelector( "nth-of-type(2)" ); + ASSERT_TRUE( nth1.selector != nullptr ); + ASSERT_TRUE( nth2.selector != nullptr ); + + UITextSpan* span0 = sceneNode->find( "span0" ); + UITextSpan* span1 = sceneNode->find( "span1" ); + ASSERT_TRUE( span0 != nullptr ); + ASSERT_TRUE( span1 != nullptr ); + + Node* br0 = sceneNode->find( "br0" ); + ASSERT_TRUE( br0 != nullptr ); + + // nth-of-type(1): first span and first br + EXPECT_TRUE( nth1.selector( span0, nth1.a, nth1.b, nth1.data ) ); + EXPECT_TRUE( nth1.selector( static_cast( br0 ), nth1.a, nth1.b, nth1.data ) ); + // nth-of-type(2): second span + EXPECT_TRUE( nth2.selector( span1, nth2.a, nth2.b, nth2.data ) ); + + destroyRichTextScene( sceneNode ); +} + +UTEST( UITextNode_CSSSelectors, OnlyOfTypeWithTextNodes ) { + auto sceneNode = createRichTextScene(); + ASSERT_TRUE( sceneNode != nullptr ); + + // One span and one br → each is only-of-type (different C++ types) + String xml = R"xml( + + a only span b
+
+ )xml"; + + sceneNode->loadLayoutFromString( xml ); + sceneNode->update( Time::Zero ); + + auto& spec = *StyleSheetSpecification::instance(); + StructuralSelector sel = spec.getStructuralSelector( "only-of-type" ); + ASSERT_TRUE( sel.selector != nullptr ); + + UITextSpan* span0 = sceneNode->find( "span0" ); + ASSERT_TRUE( span0 != nullptr ); + + Node* br0 = sceneNode->find( "br0" ); + ASSERT_TRUE( br0 != nullptr ); + + // Each is the only one of its C++ type + EXPECT_TRUE( sel.selector( span0, 0, 0, FunctionString::parse( "" ) ) ); + EXPECT_TRUE( sel.selector( static_cast( br0 ), 0, 0, FunctionString::parse( "" ) ) ); + + destroyRichTextScene( sceneNode ); +} + +UTEST( UITextNode_CSSSelectors, LastOfTypeWithTextNodes ) { + auto sceneNode = createRichTextScene(); + ASSERT_TRUE( sceneNode != nullptr ); + + // span0, br0, span1 → span0 not last, span1 is last span, br0 is last br + String xml = R"xml( + + a 1 b
c 2
+
+ )xml"; + + sceneNode->loadLayoutFromString( xml ); + sceneNode->update( Time::Zero ); + + auto& spec = *StyleSheetSpecification::instance(); + StructuralSelector sel = spec.getStructuralSelector( "last-of-type" ); + ASSERT_TRUE( sel.selector != nullptr ); + + UITextSpan* span0 = sceneNode->find( "span0" ); + UITextSpan* span1 = sceneNode->find( "span1" ); + ASSERT_TRUE( span0 != nullptr ); + ASSERT_TRUE( span1 != nullptr ); + + Node* br0 = sceneNode->find( "br0" ); + ASSERT_TRUE( br0 != nullptr ); + + EXPECT_FALSE( sel.selector( span0, 0, 0, FunctionString::parse( "" ) ) ); + EXPECT_TRUE( sel.selector( span1, 0, 0, FunctionString::parse( "" ) ) ); + EXPECT_TRUE( sel.selector( static_cast( br0 ), 0, 0, FunctionString::parse( "" ) ) ); + + destroyRichTextScene( sceneNode ); +} + +UTEST( UITextNode_CSSSelectors, NthLastChildWithTextNodes ) { + auto sceneNode = createRichTextScene(); + ASSERT_TRUE( sceneNode != nullptr ); + + String xml = R"xml( + + 1 a 2 + + )xml"; + + sceneNode->loadLayoutFromString( xml ); + sceneNode->update( Time::Zero ); + + auto& spec = *StyleSheetSpecification::instance(); + StructuralSelector nthLast1 = spec.getStructuralSelector( "nth-last-child(1)" ); + StructuralSelector nthLast2 = spec.getStructuralSelector( "nth-last-child(2)" ); + ASSERT_TRUE( nthLast1.selector != nullptr ); + ASSERT_TRUE( nthLast2.selector != nullptr ); + + UITextSpan* elem0 = sceneNode->find( "elem0" ); + UITextSpan* elem1 = sceneNode->find( "elem1" ); + ASSERT_TRUE( elem0 != nullptr ); + ASSERT_TRUE( elem1 != nullptr ); + + EXPECT_TRUE( nthLast1.selector( elem1, nthLast1.a, nthLast1.b, nthLast1.data ) ); + EXPECT_TRUE( nthLast2.selector( elem0, nthLast2.a, nthLast2.b, nthLast2.data ) ); + + destroyRichTextScene( sceneNode ); +} + +UTEST( UITextNode_CSSSelectors, NthLastOfTypeWithTextNodes ) { + auto sceneNode = createRichTextScene(); + ASSERT_TRUE( sceneNode != nullptr ); + + // span0, br0, span1 → nth-last-of-type(1): span1 and br0, nth-last-of-type(2): span0 + String xml = R"xml( + + 1 a 2 b
+
+ )xml"; + + sceneNode->loadLayoutFromString( xml ); + sceneNode->update( Time::Zero ); + + auto& spec = *StyleSheetSpecification::instance(); + StructuralSelector nthLast1 = spec.getStructuralSelector( "nth-last-of-type(1)" ); + StructuralSelector nthLast2 = spec.getStructuralSelector( "nth-last-of-type(2)" ); + ASSERT_TRUE( nthLast1.selector != nullptr ); + ASSERT_TRUE( nthLast2.selector != nullptr ); + + UITextSpan* span0 = sceneNode->find( "span0" ); + UITextSpan* span1 = sceneNode->find( "span1" ); + ASSERT_TRUE( span0 != nullptr ); + ASSERT_TRUE( span1 != nullptr ); + + Node* br0 = sceneNode->find( "br0" ); + ASSERT_TRUE( br0 != nullptr ); + + EXPECT_TRUE( nthLast1.selector( span1, nthLast1.a, nthLast1.b, nthLast1.data ) ); + EXPECT_TRUE( nthLast2.selector( span0, nthLast2.a, nthLast2.b, nthLast2.data ) ); + EXPECT_TRUE( + nthLast1.selector( static_cast( br0 ), nthLast1.a, nthLast1.b, nthLast1.data ) ); + + destroyRichTextScene( sceneNode ); +} + +// ============================================================ +// Suite: UITextNode_XMLParsing +// ============================================================ + +UTEST( UITextNode_XMLParsing, PcdataCreatesUITextNode ) { + auto sceneNode = createRichTextScene(); + ASSERT_TRUE( sceneNode != nullptr ); + + String xml = R"xml( + + Hello World + + )xml"; + + sceneNode->loadLayoutFromString( xml ); + + UIRichText* rt = sceneNode->find( "rt" ); + ASSERT_TRUE( rt != nullptr ); + + Node* child = rt->getFirstChild(); + ASSERT_TRUE( child != nullptr ); + EXPECT_TRUE( child->isTextNode() ); + + UITextNode* textNode = static_cast( child ); + EXPECT_STDSTREQ( textNode->getText().toUtf8(), "Hello World" ); + + destroyRichTextScene( sceneNode ); +} + +UTEST( UITextNode_XMLParsing, MixedElementsAndText ) { + auto sceneNode = createRichTextScene(); + ASSERT_TRUE( sceneNode != nullptr ); + + String xml = R"xml( + + before bold after + + )xml"; + + sceneNode->loadLayoutFromString( xml ); + sceneNode->update( Time::Zero ); + + UIRichText* rt = sceneNode->find( "rt" ); + ASSERT_TRUE( rt != nullptr ); + + int textNodeCount = 0; + int elementCount = 0; + Node* child = rt->getFirstChild(); + while ( child ) { + if ( child->isTextNode() ) + textNodeCount++; + else + elementCount++; + child = child->getNextNode(); + } + + EXPECT_GE( textNodeCount, 1 ); + EXPECT_GE( elementCount, 1 ); + + destroyRichTextScene( sceneNode ); +} + +UTEST( UITextNode_XMLParsing, NestedSpanTextContent ) { + auto sceneNode = createRichTextScene(); + ASSERT_TRUE( sceneNode != nullptr ); + + String xml = R"xml( + + text nested after + + )xml"; + + sceneNode->loadLayoutFromString( xml ); + sceneNode->update( Time::Zero ); + + UITextSpan* outer = sceneNode->find( "outer" ); + UITextSpan* inner = sceneNode->find( "inner" ); + ASSERT_TRUE( outer != nullptr ); + ASSERT_TRUE( inner != nullptr ); + + // Inner span: no child elements → text is in own mText + EXPECT_STDSTREQ( inner->getText().toUtf8(), "nested" ); + + // Outer span: has child elements → text is in child UITextNodes + bool foundTextNode = false; + Node* child = outer->getFirstChild(); + while ( child ) { + if ( child->isTextNode() ) { + foundTextNode = true; + UITextNode* tn = static_cast( child ); + EXPECT_FALSE( tn->getText().empty() ); + } + child = child->getNextNode(); + } + EXPECT_TRUE( foundTextNode ); + + destroyRichTextScene( sceneNode ); +} + +// ============================================================ +// Suite: UITextNode_RichTextRebuild +// ============================================================ + +UTEST( UITextNode_RichTextRebuild, TextFromNodesAppearsInRichText ) { + auto sceneNode = createRichTextScene(); + ASSERT_TRUE( sceneNode != nullptr ); + + String xml = R"xml( + + Hello World + + )xml"; + + sceneNode->loadLayoutFromString( xml ); + sceneNode->update( Time::Zero ); + + UIRichText* rt = sceneNode->find( "rt" ); + ASSERT_TRUE( rt != nullptr ); + + Int64 charCount = rt->getRichText().getCharacterCount(); + EXPECT_GE( charCount, 10 ); // "Hello World" has 11 chars + + destroyRichTextScene( sceneNode ); +} + +UTEST( UITextNode_RichTextRebuild, MixedContentAppearsInRichText ) { + auto sceneNode = createRichTextScene(); + ASSERT_TRUE( sceneNode != nullptr ); + + String xml = R"xml( + + before bold after + + )xml"; + + sceneNode->loadLayoutFromString( xml ); + sceneNode->update( Time::Zero ); + + UIRichText* rt = sceneNode->find( "rt" ); + ASSERT_TRUE( rt != nullptr ); + + int textBlocks = 0; + for ( const auto& block : rt->getRichTextPtr()->getBlocks() ) { + if ( std::holds_alternative( block ) ) + textBlocks++; + } + EXPECT_GE( textBlocks, 3 ); // "before ", "bold", " after" + + destroyRichTextScene( sceneNode ); +} + +// ============================================================ +// Suite: UITextNode_StyleInheritance +// ============================================================ + +UTEST( UITextNode_StyleInheritance, InheritsFontSizeFromParent ) { + for ( Float scale : { 1.f, 1.5f, 2.f } ) { + UTEST_PRINT_STEP( String::format( "SCALE %.1f", scale ).c_str() ); + UIApplication app( WindowSettings( 800, 600, "eepp - UITextNode Inheritance Test", + WindowStyle::Default, WindowBackend::Default, 32 ), + UIApplication::Settings( + Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), scale ) ); + + std::string xmlStr = R"( + + + + + +

test text

+ + + )"; + + UIWidget* root = app.getUI()->loadLayoutFromString( xmlStr ); + EXPECT_TRUE( root != nullptr ); + + UIRichText* h1 = root->querySelector( "#testh1" )->asType(); + EXPECT_TRUE( h1 != nullptr ); + + EXPECT_NEAR( 32u * scale, h1->getFontSize(), 1.f ); + + Node* child = h1->getFirstChild(); + EXPECT_TRUE( child != nullptr ); + EXPECT_TRUE( child->isWidget() ); + EXPECT_TRUE( child->isTextNode() ); + + UIWidget* childWidget = child->asType(); + std::string pxStr = childWidget->getPropertyString( + StyleSheetSpecification::instance()->getProperty( PropertyId::FontSize ) ); + EXPECT_FALSE( pxStr.empty() ); + EXPECT_NEAR( 32u * scale, + childWidget->lengthFromValue( StyleSheetProperty( "font-size", pxStr ) ), + 1.f ); + } +} + +// ============================================================ +// Suite: UITextNode_BlockLayouter +// ============================================================ + +UTEST( UITextNode_BlockLayouter, SpanBoundsCoverChildTextNodes ) { + auto sceneNode = createRichTextScene(); + ASSERT_TRUE( sceneNode != nullptr ); + + // A span with child text nodes AND a child element → bounds should cover all + String xml = R"xml( + + + before bold after + + + )xml"; + + sceneNode->loadLayoutFromString( xml ); + + UITextSpan* sp = sceneNode->find( "sp" ); + ASSERT_TRUE( sp != nullptr ); + + sceneNode->update( Time::Zero ); + + EXPECT_GT( sp->getPixelsSize().getWidth(), 0.f ); + EXPECT_GT( sp->getPixelsSize().getHeight(), 0.f ); + + destroyRichTextScene( sceneNode ); +} + +UTEST( UITextNode_BlockLayouter, SimpleSpanHasBounds ) { + auto sceneNode = createRichTextScene(); + ASSERT_TRUE( sceneNode != nullptr ); + + String xml = R"xml( + + Just text + + )xml"; + + sceneNode->loadLayoutFromString( xml ); + + UITextSpan* sp = sceneNode->find( "sp" ); + ASSERT_TRUE( sp != nullptr ); + + sceneNode->update( Time::Zero ); + + EXPECT_GT( sp->getPixelsSize().getWidth(), 0.f ); + EXPECT_GT( sp->getPixelsSize().getHeight(), 0.f ); + + destroyRichTextScene( sceneNode ); +} + +UTEST( UITextNode_BlockLayouter, LinkSpanHasHitBoxesAndBounds ) { + auto sceneNode = createRichTextScene(); + ASSERT_TRUE( sceneNode != nullptr ); + + String xml = R"xml( + + before link after + + )xml"; + + sceneNode->loadLayoutFromString( xml ); + + UITextSpan* link = sceneNode->find( "link" ); + ASSERT_TRUE( link != nullptr ); + + sceneNode->update( Time::Zero ); + + EXPECT_GT( link->getPixelsSize().getWidth(), 0.f ); + EXPECT_GT( link->getPixelsSize().getHeight(), 0.f ); + EXPECT_FALSE( link->getHitBoxes().empty() ); + + destroyRichTextScene( sceneNode ); +} + +UTEST( UITextNode_BlockLayouter, OuterSpanHitBoxesCoverTextNodes ) { + auto sceneNode = createRichTextScene(); + ASSERT_TRUE( sceneNode != nullptr ); + + // Outer span has child text nodes that should be included in bounds + String xml = R"xml( + + + + before inner text after + + + + )xml"; + + sceneNode->loadLayoutFromString( xml ); + sceneNode->update( Time::Zero ); + + UITextSpan* outer = sceneNode->find( "outer" ); + ASSERT_TRUE( outer != nullptr ); + + EXPECT_GT( outer->getPixelsSize().getWidth(), 0.f ); + EXPECT_GT( outer->getPixelsSize().getHeight(), 0.f ); + + destroyRichTextScene( sceneNode ); +} + +UTEST( UITextNode_BlockLayouter, HitBoxLocalPositionsAreAtOrigin ) { + auto sceneNode = createRichTextScene(); + ASSERT_TRUE( sceneNode != nullptr ); + + // Simple span with own text: hitboxes should be at (0,0) in local space + String xml = R"xml( + + + Link + + + )xml"; + + sceneNode->loadLayoutFromString( xml ); + sceneNode->update( Time::Zero ); + + UIWidget* anchor = sceneNode->find( "anchor" ); + ASSERT_TRUE( anchor != nullptr ); + + // The hitboxes should be non-empty + UITextSpan* anchorSpan = anchor->asType(); + EXPECT_FALSE( anchorSpan->getHitBoxes().empty() ); + + if ( !anchorSpan->getHitBoxes().empty() ) { + const Rectf& firstHb = anchorSpan->getHitBoxes()[0]; + // Hitboxes should be near (0,0) in widget-local space + EXPECT_NEAR( firstHb.Left, 0.f, 2.f ); + EXPECT_NEAR( firstHb.Top, 0.f, 2.f ); + } + + destroyRichTextScene( sceneNode ); +} + +UTEST( UITextNode_BlockLayouter, OverFindHitsAnchorWhenMatchingText ) { + auto sceneNode = createRichTextScene(); + ASSERT_TRUE( sceneNode != nullptr ); + + // Mixed text nodes + anchor: overFind should return the anchor when + // clicking inside its hitbox + String xml = R"xml( + + + before Link after + + + )xml"; + + sceneNode->loadLayoutFromString( xml ); + sceneNode->update( Time::Zero ); + + UITextSpan* anchor = sceneNode->find( "anchor" ); + UIRichText* rt = sceneNode->find( "rt" ); + ASSERT_TRUE( anchor != nullptr ); + ASSERT_TRUE( rt != nullptr ); + + EXPECT_FALSE( anchor->getHitBoxes().empty() ); + + if ( !anchor->getHitBoxes().empty() ) { + const Rectf& firstHb = anchor->getHitBoxes()[0]; + Vector2f hitPos = anchor->convertToWorldSpace( + { firstHb.Left + 1, firstHb.Top + 1 } ); + Node* hitNode = rt->overFind( hitPos ); + EXPECT_EQ( hitNode, anchor ); + } + + destroyRichTextScene( sceneNode ); +} + +UTEST( UITextNode_BlockLayouter, NestedSpanOverFindHitsInnerSpan ) { + auto sceneNode = createRichTextScene(); + ASSERT_TRUE( sceneNode != nullptr ); + + // Outer span contains text nodes and an inner span. + // Clicking inside the inner span's hitbox should return the inner span. + String xml = R"xml( + + + before bold after + + + )xml"; + + sceneNode->loadLayoutFromString( xml ); + sceneNode->update( Time::Zero ); + + UITextSpan* inner = sceneNode->find( "inner" ); + UIRichText* rt = sceneNode->find( "rt" ); + ASSERT_TRUE( inner != nullptr ); + ASSERT_TRUE( rt != nullptr ); + + EXPECT_FALSE( inner->getHitBoxes().empty() ); + + if ( !inner->getHitBoxes().empty() ) { + const Rectf& firstHb = inner->getHitBoxes()[0]; + Vector2f hitPos = inner->convertToWorldSpace( + { firstHb.Left + 1, firstHb.Top + 1 } ); + Node* hitNode = rt->overFind( hitPos ); + EXPECT_TRUE( hitNode == inner || hitNode->inParentTreeOf( inner ) ); + } + + destroyRichTextScene( sceneNode ); +} + +// ============================================================ +// Suite: UITextNode_EdgeCases +// ============================================================ + +UTEST( UITextNode_EdgeCases, EmptyTextNodesDontAffectLayout ) { + auto sceneNode = createRichTextScene(); + ASSERT_TRUE( sceneNode != nullptr ); + + UITextNode* tn = UITextNode::New(); + tn->setParent( sceneNode->getRoot() ); + EXPECT_TRUE( tn->getText().empty() ); + eeDelete( tn ); + + // Whitespace-only text should be collapsed + String xml = R"xml( + + + + )xml"; + + sceneNode->loadLayoutFromString( xml ); + sceneNode->update( Time::Zero ); + + UIRichText* rt = sceneNode->find( "rt" ); + ASSERT_TRUE( rt != nullptr ); + + Node* child = rt->getFirstChild(); + if ( child ) { + EXPECT_TRUE( child->isTextNode() ); + UITextNode* textNode = static_cast( child ); + EXPECT_TRUE( textNode->getText().empty() || String::trim( textNode->getText() ).empty() ); + } + + destroyRichTextScene( sceneNode ); +} + +UTEST( UITextNode_EdgeCases, DirectChildOfRichText ) { + auto sceneNode = createRichTextScene(); + ASSERT_TRUE( sceneNode != nullptr ); + + String xml = R"xml( + + Direct text content + + )xml"; + + sceneNode->loadLayoutFromString( xml ); + sceneNode->update( Time::Zero ); + + UIRichText* rt = sceneNode->find( "rt" ); + ASSERT_TRUE( rt != nullptr ); + + int textNodeCount = 0; + Node* child = rt->getFirstChild(); + while ( child ) { + if ( child->isTextNode() ) { + textNodeCount++; + UITextNode* tn = static_cast( child ); + EXPECT_FALSE( tn->getText().empty() ); + } + child = child->getNextNode(); + } + EXPECT_EQ( textNodeCount, 1 ); + + destroyRichTextScene( sceneNode ); +} + +// ============================================================ +// Suite: UITextNode_RegressionTests +// ============================================================ + +UTEST( UITextNode_Regression, WhitespaceCollapseDoesNotCreateSpuriousNodes ) { + auto sceneNode = createRichTextScene(); + ASSERT_TRUE( sceneNode != nullptr ); + + String xml = R"xml( + + Hello World. HI in monospace! + + )xml"; + + sceneNode->loadLayoutFromString( xml ); + sceneNode->update( Time::Zero ); + + UIRichText* rt = sceneNode->find( "rt" ); + ASSERT_TRUE( rt != nullptr ); + + // The ". " text after "World" should be in a UITextNode + bool foundDotSpace = false; + Node* child = rt->getFirstChild(); + while ( child ) { + if ( child->isTextNode() ) { + UITextNode* span = static_cast( child ); + if ( span->getText() == ". " ) { + foundDotSpace = true; + } + } + child = child->getNextNode(); + } + EXPECT_TRUE( foundDotSpace ); + + destroyRichTextScene( sceneNode ); +} + +UTEST( UITextNode_Regression, ElementCountingDoesNotCountDeletedNodes ) { + auto sceneNode = createRichTextScene(); + ASSERT_TRUE( sceneNode != nullptr ); + + String xml = R"xml( + + text only + + )xml"; + + sceneNode->loadLayoutFromString( xml ); + sceneNode->update( Time::Zero ); + + auto& spec = *StyleSheetSpecification::instance(); + StructuralSelector sel = spec.getStructuralSelector( "first-child" ); + ASSERT_TRUE( sel.selector != nullptr ); + + UITextSpan* elem0 = sceneNode->find( "elem0" ); + ASSERT_TRUE( elem0 != nullptr ); + + // elem0 is first element child (text node skipped) + EXPECT_TRUE( sel.selector( elem0, 0, 0, FunctionString::parse( "" ) ) ); + + destroyRichTextScene( sceneNode ); +} + +UTEST( UITextNode_Regression, OnlyOfTypeStaysCorrectAfterModification ) { + auto sceneNode = createRichTextScene(); + ASSERT_TRUE( sceneNode != nullptr ); + + // One span and one br → each is only-of-type (different C++ types) + String xml = R"xml( + + a only span b
+
+ )xml"; + + sceneNode->loadLayoutFromString( xml ); + sceneNode->update( Time::Zero ); + + auto& spec = *StyleSheetSpecification::instance(); + StructuralSelector sel = spec.getStructuralSelector( "only-of-type" ); + ASSERT_TRUE( sel.selector != nullptr ); + + UITextSpan* span0 = sceneNode->find( "span0" ); + ASSERT_TRUE( span0 != nullptr ); + + Node* br0 = sceneNode->find( "br0" ); + ASSERT_TRUE( br0 != nullptr ); + + // Each is only-of-type + EXPECT_TRUE( sel.selector( span0, 0, 0, FunctionString::parse( "" ) ) ); + EXPECT_TRUE( sel.selector( static_cast( br0 ), 0, 0, FunctionString::parse( "" ) ) ); + + // Remove br0 → span0 still only-of-type + br0->detach(); + eeDelete( br0 ); + + EXPECT_TRUE( sel.selector( span0, 0, 0, FunctionString::parse( "" ) ) ); + + destroyRichTextScene( sceneNode ); +}