From 311dffba7c60f7ea2eba40e06edbb13f6230fde4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Lucas=20Golini?= Date: Wed, 8 Apr 2026 00:28:23 -0300 Subject: [PATCH] Improved RichText custom sizes to indicate if it's a block. --- .../html/blog_main_incorrect_widths.html | 376 ++++++++++++++++++ include/eepp/graphics/richtext.hpp | 10 +- src/eepp/graphics/richtext.cpp | 38 +- src/eepp/ui/uirichtext.cpp | 18 +- src/tests/unit_tests/richtext.cpp | 216 +++++++++- 5 files changed, 618 insertions(+), 40 deletions(-) create mode 100644 bin/unit_tests/assets/html/blog_main_incorrect_widths.html diff --git a/bin/unit_tests/assets/html/blog_main_incorrect_widths.html b/bin/unit_tests/assets/html/blog_main_incorrect_widths.html new file mode 100644 index 000000000..3372c0f55 --- /dev/null +++ b/bin/unit_tests/assets/html/blog_main_incorrect_widths.html @@ -0,0 +1,376 @@ + + + + + + + +
+ +
+ +
+ + +
+

Personal pieces of rambling reflections and rants, tangential thoughts and tirades.

+ +
+ + +
+ + +
+
+
+

+ + No, I Won't Download Your App. The Web Version is A-OK. +

+ Apr 6, 2026 +
+
+ Read post + +
+
+
+
+ + +
+
+
+

+ + The 667MHz Machine +

+ Mar 25, 2026 +
+
+ Read post + +
+
+
+
+ + +
+
+
+

+ + Never Buy A .online Domain +

+ Feb 25, 2026 +
+
+ Read post + +
+
+
+
+ + +
+
+
+

+ + Accelerated FOMO in the Age of AI +

+ Feb 23, 2026 +
+
+ Read post + +
+
+
+
+ + +
+
+
+

+ + ai;dr +

+ Feb 12, 2026 +
+
+ Read post + +
+
+
+
+ + +
+
+
+

+ + App Store Review Feels Like RNG, and That’s the Problem +

+ Feb 5, 2026 +
+
+ Read post + +
+
+
+
+ + +
+
+
+

+ + Welcome to the Machine +

+ Feb 3, 2026 +
+
+ Read post + +
+
+
+
+ + +
+ +
+ + diff --git a/include/eepp/graphics/richtext.hpp b/include/eepp/graphics/richtext.hpp index f087fed29..4cfba07cd 100644 --- a/include/eepp/graphics/richtext.hpp +++ b/include/eepp/graphics/richtext.hpp @@ -77,7 +77,12 @@ class EE_API RichText : public Drawable { enum class BlockType { Text, Drawable, CustomSize }; - using Block = std::variant, std::shared_ptr, Sizef>; + struct CustomBlock { + Sizef size; + bool isBlock{ false }; + }; + + using Block = std::variant, std::shared_ptr, CustomBlock>; /** * @brief Adds a drawable (e.g., an image) into the text flow. @@ -88,8 +93,9 @@ class EE_API RichText : public Drawable { /** * @brief Adds a custom size spacer into the text flow. * @param size The physical dimensions of the spacer. + * @param isBlock Whether this spacer acts as a block-level element. */ - void addCustomSize( const Sizef& size ); + void addCustomSize( const Sizef& size, bool isBlock = false ); /** @return The list of blocks. */ const std::vector& getBlocks() { return mBlocks; } diff --git a/src/eepp/graphics/richtext.cpp b/src/eepp/graphics/richtext.cpp index f470f9f0a..1bacef8cd 100644 --- a/src/eepp/graphics/richtext.cpp +++ b/src/eepp/graphics/richtext.cpp @@ -95,7 +95,7 @@ void RichText::draw( const Float& X, const Float& Y, const Vector2f& scale, cons span.size ); } }, - []( const Sizef& ) {} }, + []( const CustomBlock& ) {} }, span.block ); } } @@ -282,8 +282,8 @@ void RichText::addDrawable( std::shared_ptr drawable ) { invalidateLayout(); } -void RichText::addCustomSize( const Sizef& size ) { - mBlocks.push_back( size ); +void RichText::addCustomSize( const Sizef& size, bool isBlock ) { + mBlocks.push_back( CustomBlock{ size, isBlock } ); invalidateLayout(); } @@ -365,8 +365,8 @@ Float RichText::getMinIntrinsicWidth() { } } else if ( auto pDrawable = std::get_if>( &block ) ) { minW = std::max( minW, ( *pDrawable )->getPixelsSize().getWidth() ); - } else if ( auto pSize = std::get_if( &block ) ) { - minW = std::max( minW, pSize->getWidth() ); + } else if ( auto pSize = std::get_if( &block ) ) { + minW = std::max( minW, pSize->size.getWidth() ); } } return minW; @@ -395,8 +395,16 @@ Float RichText::getMaxIntrinsicWidth() { span->getTextHints() ); } else if ( auto pDrawable = std::get_if>( &block ) ) { curX += ( *pDrawable )->getPixelsSize().getWidth(); - } else if ( auto pSize = std::get_if( &block ) ) { - curX += pSize->getWidth(); + } else if ( auto pSize = std::get_if( &block ) ) { + if ( pSize->isBlock ) { + if ( curX > 0 ) { + maxW = std::max( maxW, curX ); + curX = 0; + } + maxW = std::max( maxW, pSize->size.getWidth() ); + } else { + curX += pSize->size.getWidth(); + } } } maxW = std::max( maxW, curX ); @@ -484,15 +492,23 @@ void RichText::updateLayout() { } } else { // Drawable or CustomSize Sizef blockSize; + bool isBlock = false; if ( auto pDrawable = std::get_if>( &block ) ) { auto& drawable = *pDrawable; blockSize = drawable ? drawable->getPixelsSize() : Sizef(); - } else if ( auto pSize = std::get_if( &block ) ) { - blockSize = *pSize; + } else if ( auto pSize = std::get_if( &block ) ) { + blockSize = pSize->size; + isBlock = pSize->isBlock; + } + + if ( isBlock && curX > 0 ) { + maxWidth = std::max( maxWidth, curX ); + mLines.push_back( RenderParagraph() ); + curX = 0; } // Wrap if needed - if ( mMaxWidth > 0 && + if ( mMaxWidth > 0 && !isBlock && ( curX + blockSize.getWidth() >= mMaxWidth || curX >= mMaxWidth ) && curX > 0 ) { maxWidth = std::max( maxWidth, curX ); mLines.push_back( RenderParagraph() ); @@ -516,7 +532,7 @@ void RichText::updateLayout() { curX += blockSize.getWidth(); currentLine.width += blockSize.getWidth(); - if ( mMaxWidth > 0 && curX >= mMaxWidth ) { + if ( ( mMaxWidth > 0 && curX >= mMaxWidth ) || isBlock ) { maxWidth = std::max( maxWidth, curX ); mLines.push_back( RenderParagraph() ); curX = 0; diff --git a/src/eepp/ui/uirichtext.cpp b/src/eepp/ui/uirichtext.cpp index 443d3f184..6251bb573 100644 --- a/src/eepp/ui/uirichtext.cpp +++ b/src/eepp/ui/uirichtext.cpp @@ -17,7 +17,7 @@ namespace EE { namespace UI { class UILineBreak : public UIRichText { public: - static UILineBreak* New( const std::string& tag = "" ) { return eeNew( UILineBreak, ( tag ) ); } + static UILineBreak* New( const std::string& tag ) { return eeNew( UILineBreak, ( tag ) ); } UILineBreak( const std::string& tag = "br" ) : UIRichText( tag ) {} @@ -29,7 +29,7 @@ class UILineBreak : public UIRichText { }; UIRichText* UIRichText::NewBr() { - return UILineBreak::New(); + return UILineBreak::New( "br" ); }; UIRichText* UIRichText::NewHr() { @@ -490,18 +490,14 @@ void UIRichText::rebuildRichText( RichText& richText, IntrinsicMode mode ) { mw = 0.f; } - if ( mWidthPolicy == SizePolicy::WrapContent || mode != IntrinsicMode::None ) { - if ( mode == IntrinsicMode::None && !mMaxWidthEq.empty() ) { - richText.setMaxWidth( mw ); - } else { - richText.setMaxWidth( 0.f ); // Let it grow unbounded to query text bounds later - } - } else { - if ( !mMaxWidthEq.empty() && mw < maxWidth ) { + if ( mode == IntrinsicMode::None ) { + if ( !mMaxWidthEq.empty() && ( maxWidth == 0 || mw < maxWidth ) ) { richText.setMaxWidth( mw ); } else { richText.setMaxWidth( maxWidth ); } + } else { + richText.setMaxWidth( 0.f ); // Let it grow unbounded to query text bounds later } auto processWidget = [&]( UIWidget* widget, auto& processWidgetRef ) -> void { @@ -577,7 +573,7 @@ void UIRichText::positionChildren() { while ( currentSpan < line.spans.size() ) { const auto& span = line.spans[currentSpan]; currentSpan++; - if ( std::holds_alternative( span.block ) ) + if ( std::holds_alternative( span.block ) ) return &span; } currentSpan = 0; diff --git a/src/tests/unit_tests/richtext.cpp b/src/tests/unit_tests/richtext.cpp index 61d3ff169..e82ab19cd 100644 --- a/src/tests/unit_tests/richtext.cpp +++ b/src/tests/unit_tests/richtext.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -22,6 +23,7 @@ using namespace EE::Graphics; using namespace EE::Window; using namespace EE::Scene; using namespace EE::UI; +using namespace EE::UI::Tools; UTEST( RichText, basicFunctionality ) { Engine::instance()->createWindow( WindowSettings( 800, 600, "RichText Test", @@ -365,8 +367,9 @@ UTEST( UIRichText, IntegrationAndLayoutVerification ) { EXPECT_TRUE( text1->getFillColor() == Color::fromString( "#FF0000" ) ); // Check CustomSize block - EXPECT_TRUE( std::holds_alternative( blocks[2] ) ); - EXPECT_EQ( std::get( blocks[2] ).getWidth(), PixelDensity::dpToPx( 50 ) ); + EXPECT_TRUE( std::holds_alternative( blocks[2] ) ); + EXPECT_EQ( std::get( blocks[2] ).size.getWidth(), + PixelDensity::dpToPx( 50 ) ); UI::UIWidget* placeholder = rt->find( "placeholder" ); ASSERT_TRUE( placeholder != nullptr ); @@ -463,10 +466,11 @@ UTEST( UIRichText, NestedWidgetsIntegration ) { // Check block types EXPECT_TRUE( std::holds_alternative>( blocks[0] ) ); EXPECT_TRUE( std::holds_alternative>( blocks[1] ) ); - EXPECT_TRUE( std::holds_alternative( blocks[2] ) ); + EXPECT_TRUE( std::holds_alternative( blocks[2] ) ); EXPECT_TRUE( std::holds_alternative>( blocks[3] ) ); - EXPECT_EQ( std::get( blocks[2] ).getWidth(), PixelDensity::dpToPx( 50 ) ); + EXPECT_EQ( std::get( blocks[2] ).size.getWidth(), + PixelDensity::dpToPx( 50 ) ); UI::UIWidget* strongNode = rt->find( "strong" ); ASSERT_TRUE( strongNode != nullptr ); @@ -1059,18 +1063,20 @@ UTEST( UIRichText, MinMaxWidth ) { EXPECT_EQ( rtMin->getSize().getWidth(), PixelDensity::dpToPx( 200 ) ); EXPECT_LE( rtMax->getSize().getWidth(), PixelDensity::dpToPx( 100 ) ); - EXPECT_GT( rtMax->getSize().getHeight(), PixelDensity::dpToPx( 30 ) ); // should wrap to multiple lines + EXPECT_GT( rtMax->getSize().getHeight(), + PixelDensity::dpToPx( 30 ) ); // should wrap to multiple lines EXPECT_LE( rtMaxFixed->getSize().getWidth(), PixelDensity::dpToPx( 100 ) ); - EXPECT_GT( rtMaxFixed->getSize().getHeight(), PixelDensity::dpToPx( 30 ) ); // should wrap to multiple lines + EXPECT_GT( rtMaxFixed->getSize().getHeight(), + PixelDensity::dpToPx( 30 ) ); // should wrap to multiple lines eeDelete( sceneNode ); Engine::destroySingleton(); } UTEST( UIRichText, MinMaxWidthChildren ) { - Engine::instance()->createWindow( WindowSettings( 800, 600, "RichText Min/Max Width Children Test", - WindowStyle::Default, WindowBackend::Default, - 32, {}, 1, false, true ) ); + Engine::instance()->createWindow( + WindowSettings( 800, 600, "RichText Min/Max Width Children Test", WindowStyle::Default, + WindowBackend::Default, 32, {}, 1, false, true ) ); FileSystem::changeWorkingDirectory( Sys::getProcessPath() ); FontTrueType* font = FontTrueType::New( "NotoSans-Regular" ); @@ -1098,7 +1104,8 @@ UTEST( UIRichText, MinMaxWidthChildren ) { ASSERT_TRUE( childWidget != nullptr ); sceneNode->update( Time::Zero ); - sceneNode->update( Time::Zero ); // Run a second pass to allow MatchParent to resolve against the new clamped parent size + sceneNode->update( Time::Zero ); // Run a second pass to allow MatchParent to resolve against + // the new clamped parent size sceneNode->update( Time::Zero ); EXPECT_LE( rtParent->getSize().getWidth(), PixelDensity::dpToPx( 100 ) ); @@ -1112,9 +1119,9 @@ UTEST( UIRichText, MinMaxWidthChildren ) { } UTEST( UIRichText, MatchParentChildPadding ) { - Engine::instance()->createWindow( WindowSettings( 800, 600, "RichText MatchParent Child Padding Test", - WindowStyle::Default, WindowBackend::Default, - 32, {}, 1, false, true ) ); + Engine::instance()->createWindow( + WindowSettings( 800, 600, "RichText MatchParent Child Padding Test", WindowStyle::Default, + WindowBackend::Default, 32, {}, 1, false, true ) ); FileSystem::changeWorkingDirectory( Sys::getProcessPath() ); FontTrueType* font = FontTrueType::New( "NotoSans-Regular" ); @@ -1153,9 +1160,9 @@ UTEST( UIRichText, MatchParentChildPadding ) { } UTEST( UILayout, MinMaxWidthChildren ) { - Engine::instance()->createWindow( WindowSettings( 800, 600, "Layout Min/Max Width Children Test", - WindowStyle::Default, WindowBackend::Default, - 32, {}, 1, false, true ) ); + Engine::instance()->createWindow( + WindowSettings( 800, 600, "Layout Min/Max Width Children Test", WindowStyle::Default, + WindowBackend::Default, 32, {}, 1, false, true ) ); FileSystem::changeWorkingDirectory( Sys::getProcessPath() ); FontTrueType* font = FontTrueType::New( "NotoSans-Regular" ); @@ -1195,3 +1202,180 @@ UTEST( UILayout, MinMaxWidthChildren ) { eeDelete( sceneNode ); Engine::destroySingleton(); } + +UTEST( UIRichText, InvalidWidthLengthComputation ) { + Engine::instance()->createWindow( WindowSettings( 800, 600, "Invalid Anchor Width", + 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 ); + + UISceneNode* sceneNode = UISceneNode::New(); + UIThemeManager* themeManager = sceneNode->getUIThemeManager(); + themeManager->setDefaultFont( font ); + + String xml = R"xml( + + )xml"; + + String html = R"html( + + + + + + + )html"; + + sceneNode->loadLayoutFromString( xml ); + auto htmlView = sceneNode->find( "html_view" ); + ASSERT_TRUE( htmlView != nullptr ); + sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( html ), htmlView ); + auto parent = sceneNode->find( "anchor_parent" ); + auto anchor = sceneNode->find( "anchor" ); + ASSERT_TRUE( parent != nullptr ); + ASSERT_TRUE( anchor != nullptr ); + + sceneNode->update( Time::Zero ); + + EXPECT_LE( anchor->getSize().getWidth(), parent->getSize().getWidth() ); + + eeDelete( sceneNode ); + Engine::destroySingleton(); +} + +UTEST( UIRichText, InvalidWidthLengthComputation2 ) { + Engine::instance()->createWindow( WindowSettings( 800, 600, "Invalid Anchor Width 2", + 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 ); + + UISceneNode* sceneNode = UISceneNode::New(); + UIThemeManager* themeManager = sceneNode->getUIThemeManager(); + themeManager->setDefaultFont( font ); + + String xml = R"xml( + + )xml"; + + String html = R"html( + + + + + + )html"; + + sceneNode->loadLayoutFromString( xml ); + auto htmlView = sceneNode->find( "html_view" ); + ASSERT_TRUE( htmlView != nullptr ); + sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( html ), htmlView ); + auto parent = sceneNode->find( "anchor_parent" ); + auto anchor = sceneNode->find( "anchor" ); + auto anchorDiv = sceneNode->find( "anchor_div" ); + auto anchorH2 = sceneNode->find( "anchor_h2" ); + auto anchorSpan = sceneNode->find( "anchor_span" ); + ASSERT_TRUE( parent != nullptr ); + ASSERT_TRUE( anchor != nullptr ); + ASSERT_TRUE( anchorDiv != nullptr ); + ASSERT_TRUE( anchorH2 != nullptr ); + ASSERT_TRUE( anchorSpan != nullptr ); + + sceneNode->update( Time::Zero ); + + EXPECT_GT( anchor->getSize().getWidth(), 0 ); + EXPECT_GT( anchorDiv->getSize().getWidth(), 0 ); + EXPECT_GT( anchorH2->getSize().getWidth(), 0 ); + EXPECT_GT( anchorSpan->getSize().getWidth(), 0 ); + EXPECT_LE( anchor->getSize().getWidth(), parent->getSize().getWidth() ); + + eeDelete( sceneNode ); + Engine::destroySingleton(); +} + +UTEST( UIRichText, InvalidWidthLengthComputation3 ) { + Engine::instance()->createWindow( WindowSettings( 800, 600, "Invalid Anchor Width 3", + 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 ); + + UISceneNode* sceneNode = UISceneNode::New(); + UIThemeManager* themeManager = sceneNode->getUIThemeManager(); + themeManager->setDefaultFont( font ); + + String xml = R"xml( + + )xml"; + + std::string html; + FileSystem::fileGet( "assets/html/blog_main_incorrect_widths.html", html ); + + sceneNode->loadLayoutFromString( xml ); + auto htmlView = sceneNode->find( "html_view" ); + ASSERT_TRUE( htmlView != nullptr ); + sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( html ), htmlView ); + + auto container = sceneNode->getRoot()->querySelector( ".container" ); + auto posts = sceneNode->getRoot()->querySelectorAll( ".post-list > a" ); + auto items = sceneNode->getRoot()->querySelectorAll( ".post-list > a > .post-item-content" ); + auto titles = sceneNode->getRoot()->querySelectorAll( + ".post-list > a > .post-item-content > .post-header-row" ); + + ASSERT_TRUE( container != nullptr ); + ASSERT_TRUE( posts.size() > 0 ); + ASSERT_TRUE( items.size() == posts.size() ); + ASSERT_TRUE( items.size() == titles.size() ); + + sceneNode->update( Time::Zero ); + + for ( size_t i = 0; i < posts.size(); i++ ) { + auto anchor = posts[i]; + auto item = items[i]; + auto title = titles[i]; + EXPECT_LE( anchor->getPixelsSize().getWidth(), container->getPixelsSize().getWidth() ); + EXPECT_LE( item->getPixelsSize().getWidth(), anchor->getPixelsSize().getWidth() ); + EXPECT_LE( title->getPixelsSize().getWidth(), item->getPixelsSize().getWidth() ); + } + + eeDelete( sceneNode ); + Engine::destroySingleton(); +}