From 246161be7e7c592ddd6a7694e513ef741ee0b5d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Lucas=20Golini?= Date: Thu, 30 Jul 2026 16:28:36 -0300 Subject: [PATCH] Fix nested float propagation in rich text layout Propagate descendant float exclusions through non-BFC HTML containers so floats participate in their nearest block formatting context. Finalize line geometry before translating propagated exclusions, track finalized lines incrementally, and search successive float bands when a fixed-width inline-block does not fit beside active floats. Recognize inline-block elements as block formatting contexts while preserving their fixed width. Reuse persistent float-exclusion storage across relayouts to avoid repeated allocations. Add SS64 regressions covering narrow viewports, HiDPI rendering, stacked floats, anchor sizing, SVG sizing, and footer overlap. Fixed `cursor: pointer`, `pointer` was not registered. --- include/eepp/graphics/richtext.hpp | 8 +- src/eepp/graphics/richtext.cpp | 125 +++++++++++++++++--- src/eepp/ui/uirichtext.cpp | 31 ++++- src/eepp/window/cursor.cpp | 2 +- src/tests/unit_tests/uihtml_float_tests.cpp | 41 +++++++ src/tests/unit_tests/uiscenenode_tests.cpp | 6 + 6 files changed, 187 insertions(+), 26 deletions(-) diff --git a/include/eepp/graphics/richtext.hpp b/include/eepp/graphics/richtext.hpp index fef19d037..b9477a100 100644 --- a/include/eepp/graphics/richtext.hpp +++ b/include/eepp/graphics/richtext.hpp @@ -169,6 +169,8 @@ class EE_API RichText : public Drawable { return mExternalFloatExclusions; } + const std::vector& getLocalFloatExclusions(); + /** @return The minimum intrinsic width of the text block. */ Float getMinIntrinsicWidth(); @@ -188,7 +190,8 @@ class EE_API RichText : public Drawable { void addCustomSize( const Sizef& size, InlineFloat floatType = InlineFloat::None, InlineClear clearType = InlineClear::None, Float baseline = -1.f, const BaselineAlignValue& baselineAlign = {}, InlineSource source = {}, - bool isBlock = false, bool isBlockFormattingContext = false ); + bool isBlock = false, bool isBlockFormattingContext = false, + std::shared_ptr> propagatedFloats = {} ); /** @brief Adds a virtual line break that is not associated with a DOM text character. */ void addLineBreak(); @@ -233,6 +236,7 @@ class EE_API RichText : public Drawable { bool isLineBreak{ false }; bool isBlock{ false }; bool isBlockFormattingContext{ false }; + std::shared_ptr> propagatedFloats; InlinePath inlinePath; Vector2f position; // Local position relative to RichText origin Sizef size; @@ -349,6 +353,7 @@ class EE_API RichText : public Drawable { bool isLineBreak{ false }; bool isBlock{ false }; bool isBlockFormattingContext{ false }; + std::shared_ptr> propagatedFloats; BaselineAlignValue baselineAlign; }; @@ -454,6 +459,7 @@ class EE_API RichText : public Drawable { RenderSpan::InlinePath mInlinePath; // Path into the inline tree for the stack-based builder std::vector mInlineFragments; std::vector mExternalFloatExclusions; + std::vector mLocalFloatExclusions; std::vector mLines; FontStyleConfig mDefaultStyle; TextSelectionRange mSelection{ 0, 0 }; diff --git a/src/eepp/graphics/richtext.cpp b/src/eepp/graphics/richtext.cpp index 6fc0c5740..e19da0623 100644 --- a/src/eepp/graphics/richtext.cpp +++ b/src/eepp/graphics/richtext.cpp @@ -737,7 +737,7 @@ class RichTextInlineLayouter { for ( const auto& run : buildLayoutRuns( inlineItems ) ) { const auto& payload = run.payload; if ( payload.type != RichText::RenderSpan::Type::Text ) { - if ( payload.floatType != RichText::InlineFloat::None || + if ( payload.floatType != RichText::InlineFloat::None || payload.propagatedFloats || payload.clearType != RichText::InlineClear::None ) return true; } @@ -1053,6 +1053,7 @@ class RichTextInlineLayouter { Float textIndent, Uint32 align, Float forcedLineHeight, const FontStyleConfig& defaultStyle, const std::vector& externalFloatExclusions, + std::vector& localFloatExclusions, bool lineWrap, RichText::WhiteSpaceWrapMode whiteSpaceWrapMode ) { LayoutResult result; result.lines.push_back( RichText::RenderParagraph() ); @@ -1134,6 +1135,27 @@ class RichTextInlineLayouter { return advanced; }; + size_t finalizedLineCount = 0; + Float finalizedLinesBottom = 0.f; + // Propagated float groups need final line metrics before translating their exclusions. + // Keep a monotonic cursor so each preceding line is finalized at most once here. + auto finalizeLinesThrough = [&]( size_t end ) { + for ( ; finalizedLineCount < end; ++finalizedLineCount ) { + auto& line = result.lines[finalizedLineCount]; + alignLineSpans( line, 0.f, defaultStyle, forcedLineHeight, true, inlineItems ); + finalizedLinesBottom = + eemax( finalizedLinesBottom, line.y + line.height ); + } + }; + + auto advanceInFlowLine = [&]() { + finalizeLinesThrough( result.lines.size() ); + curY = eemax( curY, finalizedLinesBottom ); + result.lines.push_back( RichText::RenderParagraph() ); + result.lines.back().y = curY; + curX = 0; + }; + for ( const auto& run : runs ) { const auto& payload = run.payload; if ( payload.type == RichText::RenderSpan::Type::Text ) { @@ -1189,8 +1211,7 @@ class RichTextInlineLayouter { inlineEndSpacing( payload, inlineItems ) ); if ( lineWrap && effW > 0 && effW < 1e9f && curX > effW ) { maxWidth = std::max( maxWidth, curX ); - result.lines.push_back( RichText::RenderParagraph() ); - curX = 0; + advanceInFlowLine(); continue; } } @@ -1209,8 +1230,7 @@ class RichTextInlineLayouter { } if ( !trailingNewlineAlreadyAdvanced ) { maxWidth = std::max( maxWidth, curX ); - result.lines.push_back( RichText::RenderParagraph() ); - curX = 0; + advanceInFlowLine(); } } } @@ -1313,7 +1333,8 @@ class RichTextInlineLayouter { Float re = floatRightEdge( curY ); Float availableWidth = re - le; if ( availableWidth > 0 && availableWidth < 1e9f ) { - if ( metrics.size.getWidth() > availableWidth + 0.01f ) + if ( metrics.isBlock && + metrics.size.getWidth() > availableWidth + 0.01f ) metrics.size.setWidth( availableWidth ); curX = le; effW = availableWidth; @@ -1322,16 +1343,24 @@ class RichTextInlineLayouter { if ( lineWrap && !metrics.isBlock && effW > 0 && effW < 1e9f && metrics.size.getWidth() > effW + 0.01f ) { - Float maxBottom = activeFloatBottom( curY ); - if ( maxBottom > curY ) { + Float placedY = curY; + Float placedWidth = effW; + while ( metrics.size.getWidth() > placedWidth + 0.01f ) { + Float nextY = activeFloatBottom( placedY ); + if ( nextY <= placedY ) + break; + placedY = nextY; + placedWidth = effectiveMaxWidthAt( placedY ); + } + if ( placedY > curY ) { maxWidth = std::max( maxWidth, curX ); if ( !result.lines.back().spans.empty() ) result.lines.push_back( RichText::RenderParagraph() ); curX = 0; - curY = maxBottom; + curY = placedY; result.lines.back().y = curY; le = floatLeftEdge( curY ); - effW = effectiveMaxWidthAt( curY ); + effW = placedWidth; } } @@ -1339,20 +1368,60 @@ class RichTextInlineLayouter { ( curX + metrics.size.getWidth() >= effW || curX >= effW ) && curX > 0 && hadLineContentBeforeSpacing ) { maxWidth = std::max( maxWidth, curX ); - result.lines.push_back( RichText::RenderParagraph() ); - curX = 0; + advanceInFlowLine(); if ( hadLineContentBeforeSpacing ) addInlineSpacingToCurrentLine( result, curX, startSpacing ); } + if ( payload.propagatedFloats ) { + finalizeLinesThrough( result.lines.size() - 1 ); + if ( finalizedLinesBottom > curY ) { + curY = finalizedLinesBottom; + result.lines.back().y = curY; + } + Float placedY = curY; + bool moved; + do { + moved = false; + for ( const auto& propagated : *payload.propagatedFloats ) { + Rectf rect = propagated.rect; + rect.move( { curX, placedY } ); + auto avoidActiveFloat = [&]( const Rectf& active ) { + if ( rect.intersect( active ) && active.Bottom > placedY ) { + placedY += active.Bottom - rect.Top; + moved = true; + } + }; + for ( const auto& active : leftFloats ) + avoidActiveFloat( active ); + for ( const auto& active : rightFloats ) + avoidActiveFloat( active ); + } + } while ( moved ); + if ( placedY > curY ) { + curY = placedY; + result.lines.back().y = curY; + } + } + appendAtomicRenderSpan( result.lines.back(), payload, metrics, curX, curCharIdx ); + if ( payload.propagatedFloats ) { + const Vector2f atomicPosition = result.lines.back().spans.back().position; + for ( const auto& propagated : *payload.propagatedFloats ) { + Rectf rect = propagated.rect; + rect.move( { atomicPosition.x, curY } ); + if ( propagated.type == RichText::InlineFloat::Left ) + leftFloats.push_back( rect ); + else if ( propagated.type == RichText::InlineFloat::Right ) + rightFloats.push_back( rect ); + } + } addInlineSpacingToCurrentLine( result, curX, endSpacing ); if ( lineWrap && effW > 0 && effW < 1e9f && curX >= effW ) { maxWidth = std::max( maxWidth, curX ); - result.lines.push_back( RichText::RenderParagraph() ); - curX = 0; + advanceInFlowLine(); } } } @@ -1386,6 +1455,16 @@ class RichTextInlineLayouter { floatBoundsBottom = std::max( floatBoundsBottom, rightFloats[i].Bottom ); floatBoundsRight = std::max( floatBoundsRight, rightFloats[i].Right ); } + // This is persistent RichText storage: clear without discarding capacity so repeated + // viewport-driven layouts do not allocate a fresh exclusion vector every time. + localFloatExclusions.clear(); + localFloatExclusions.reserve( leftFloats.size() + rightFloats.size() - + externalLeftFloatCount - externalRightFloatCount ); + for ( size_t i = externalLeftFloatCount; i < leftFloats.size(); ++i ) + localFloatExclusions.push_back( { leftFloats[i], RichText::InlineFloat::Left } ); + for ( size_t i = externalRightFloatCount; i < rightFloats.size(); ++i ) + localFloatExclusions.push_back( + { rightFloats[i], RichText::InlineFloat::Right } ); result.size = Sizef( std::max( maxWidth, floatBoundsRight ), std::max( accumY, floatBoundsBottom ) ); @@ -1638,6 +1717,7 @@ class RichTextInlineLayouter { run.payload.isLineBreak = box.isLineBreak; run.payload.isBlock = box.isBlock; run.payload.isBlockFormattingContext = box.isBlockFormattingContext; + run.payload.propagatedFloats = box.propagatedFloats; run.payload.baselineAlign = box.baselineAlign; run.payload.inlinePath = path; run.payload._leafIndex = nextLeafIndex++; @@ -2116,9 +2196,11 @@ void RichText::addDrawable( std::shared_ptr drawable ) { invalidateLayout(); } -void RichText::addCustomSize( const Sizef& size, InlineFloat floatType, InlineClear clearType, - Float baseline, const BaselineAlignValue& baselineAlign, - InlineSource source, bool isBlock, bool isBlockFormattingContext ) { +void RichText::addCustomSize( + const Sizef& size, InlineFloat floatType, InlineClear clearType, Float baseline, + const BaselineAlignValue& baselineAlign, InlineSource source, bool isBlock, + bool isBlockFormattingContext, + std::shared_ptr> propagatedFloats ) { Float usedBaseline = baseline >= 0.f ? baseline : size.getHeight(); InlineItem item; @@ -2130,6 +2212,7 @@ void RichText::addCustomSize( const Sizef& size, InlineFloat floatType, InlineCl box.clearType = clearType; box.isBlock = isBlock; box.isBlockFormattingContext = isBlockFormattingContext; + box.propagatedFloats = std::move( propagatedFloats ); box.baselineAlign = baselineAlign; item.data = std::move( box ); resolveInlinePath( mInlineItems, mInlinePath )->push_back( std::move( item ) ); @@ -2359,6 +2442,7 @@ void RichText::updateLayout() { mAlign, mLineHeight, mDefaultStyle, mLineWrap, mWhiteSpaceWrapMode ); mLines = std::move( result.lines ); + mLocalFloatExclusions.clear(); mSize = result.size; mTotalCharacterCount = result.totalCharacterCount; rebuildInlineFragments(); @@ -2368,7 +2452,7 @@ void RichText::updateLayout() { auto result = RichTextInlineLayouter::layoutWithFloats( mInlineItems, mMaxWidth, mTextIndent, mAlign, mLineHeight, mDefaultStyle, - mExternalFloatExclusions, mLineWrap, mWhiteSpaceWrapMode ); + mExternalFloatExclusions, mLocalFloatExclusions, mLineWrap, mWhiteSpaceWrapMode ); mLines = std::move( result.lines ); mSize = result.size; mTotalCharacterCount = result.totalCharacterCount; @@ -2376,6 +2460,11 @@ void RichText::updateLayout() { mNeedsLayoutUpdate = false; } +const std::vector& RichText::getLocalFloatExclusions() { + updateLayout(); + return mLocalFloatExclusions; +} + Sizef RichText::getSize() { updateLayout(); return mSize; diff --git a/src/eepp/ui/uirichtext.cpp b/src/eepp/ui/uirichtext.cpp index 21114c592..4655af567 100644 --- a/src/eepp/ui/uirichtext.cpp +++ b/src/eepp/ui/uirichtext.cpp @@ -2116,7 +2116,7 @@ void UIRichText::rebuildRichText( UILayout* container, RichText& richText, Intri bool isFloating = floatType != CSSFloat::None; bool isNormalFlowBlock = isBlock && !isFloating; bool isBlockFormattingContext = - isNormalFlowBlock && widget->isType( UI_TYPE_HTML_WIDGET ) && + !isFloating && widget->isType( UI_TYPE_HTML_WIDGET ) && widget->asType()->establishesBlockFormattingContext(); bool shrinkToFitFloat = isFloating && widget->getLayoutWidthPolicy() == SizePolicy::MatchParent; @@ -2145,11 +2145,30 @@ void UIRichText::rebuildRichText( UILayout* container, RichText& richText, Intri Sizef customSize( w + margin.Left + margin.Right, size.getHeight() + margin.Top + margin.Bottom ); - richText.addCustomSize( - customSize, toRichTextFloat( floatType ), toRichTextClear( clearType ), - getAtomicInlineBoxBaseline( widget, size, margin ), - toRichTextBaselineAlign( getWidgetBaselineAlign( widget ) ), - toRichTextWidgetSource( widget ), isNormalFlowBlock, isBlockFormattingContext ); + std::shared_ptr> propagatedFloats; + if ( widget->isType( UI_TYPE_HTML_WIDGET ) ) { + auto* htmlWidget = widget->asType(); + if ( !htmlWidget->establishesBlockFormattingContext() && + htmlWidget->getRichTextPtr() ) { + const auto& localFloats = + htmlWidget->getRichTextPtr()->getLocalFloatExclusions(); + if ( !localFloats.empty() ) { + propagatedFloats = + std::make_shared>( + localFloats ); + const Rectf contentOffset = widget->getPixelsContentOffset(); + for ( auto& exclusion : *propagatedFloats ) + exclusion.rect.move( { margin.Left + contentOffset.Left, + margin.Top + contentOffset.Top } ); + } + } + } + richText.addCustomSize( customSize, toRichTextFloat( floatType ), + toRichTextClear( clearType ), + getAtomicInlineBoxBaseline( widget, size, margin ), + toRichTextBaselineAlign( getWidgetBaselineAlign( widget ) ), + toRichTextWidgetSource( widget ), isNormalFlowBlock, + isBlockFormattingContext, std::move( propagatedFloats ) ); if ( widget->isType( UI_TYPE_TEXTSPAN ) && widget->asType()->isInlineBlock() && diff --git a/src/eepp/window/cursor.cpp b/src/eepp/window/cursor.cpp index 9aec2d73a..f3711bf7d 100644 --- a/src/eepp/window/cursor.cpp +++ b/src/eepp/window/cursor.cpp @@ -63,7 +63,7 @@ Cursor::Type Cursor::fromName( std::string name ) { String::toLowerInPlace( name ); if ( "arrow" == name ) return Arrow; - if ( "hand" == name ) + if ( "hand" == name || "pointer" == name ) return Hand; if ( "ibream" == name ) return IBeam; diff --git a/src/tests/unit_tests/uihtml_float_tests.cpp b/src/tests/unit_tests/uihtml_float_tests.cpp index fbb4b77af..8eb8405ba 100644 --- a/src/tests/unit_tests/uihtml_float_tests.cpp +++ b/src/tests/unit_tests/uihtml_float_tests.cpp @@ -618,14 +618,21 @@ UTEST( UIHTMLFloat, floatedListItemsShrinkToFitBlockAnchors ) { UTEST( UIHTMLFloat, ss64BlockAnchorsAndSvgAtPixelDensity2 ) { init_float_test(); PixelDensity::setPixelDensity( 2.f ); + Engine::instance()->getCurrentWindow()->setSize( 475, 900 ); UISceneNode* sceneNode = SceneManager::instance()->getUISceneNode(); std::string html; ASSERT_TRUE( FileSystem::fileGet( "assets/html/ss64.html", html ) ); sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( html ) ); sceneNode->updateDirtyLayouts(); + SceneManager::instance()->update(); auto buttons = sceneNode->getRoot()->querySelectorAll( ".tbtn" ); auto anchors = sceneNode->getRoot()->querySelectorAll( ".tbtn a" ); + auto worldRect = []( UIWidget* widget ) { + Vector2f position = widget->getPixelsPosition(); + widget->nodeToWorldTranslation( position ); + return Rectf( position, widget->getPixelsSize() ); + }; ASSERT_EQ( buttons.size(), (size_t)8 ); ASSERT_EQ( anchors.size(), buttons.size() ); for ( size_t i = 0; i < buttons.size(); ++i ) { @@ -634,6 +641,8 @@ UTEST( UIHTMLFloat, ss64BlockAnchorsAndSvgAtPixelDensity2 ) { EXPECT_NEAR( anchor->getPixelsSize().getHeight(), 80.f, 1.f ); EXPECT_NEAR( anchor->getPixelsSize().getHeight() + 4.f, buttons[i]->getPixelsSize().getHeight(), 1.f ); + for ( size_t j = 0; j < i; ++j ) + EXPECT_FALSE( worldRect( buttons[i] ).intersect( worldRect( buttons[j] ) ) ); } auto* svg = sceneNode->getRoot()->querySelector( "#sherlock svg" )->asType(); auto* input = sceneNode->getRoot()->querySelector( "#qu" ); @@ -656,10 +665,42 @@ UTEST( UIHTMLFloat, ss64BlockAnchorsAndSvgAtPixelDensity2 ) { 1.f ); EXPECT_NEAR( svg->getDrawable()->getPixelsSize().getHeight(), svg->getPixelsSize().getHeight(), 1.f ); + auto* footer = sceneNode->getRoot()->querySelector( ".footer" )->asType(); + ASSERT_TRUE( footer != nullptr ); + for ( auto* button : buttons ) { + EXPECT_FALSE( worldRect( footer ).intersect( worldRect( button ) ) ); + } Engine::destroySingleton(); PixelDensity::setPixelDensity( 1.f ); } +UTEST( UIHTMLFloat, ss64NarrowViewportFloatsDoNotOverlapAtPixelDensity1 ) { + init_float_test(); + auto* window = Engine::instance()->getCurrentWindow(); + ASSERT_TRUE( window != nullptr ); + window->setSize( 400, 600 ); + UISceneNode* sceneNode = SceneManager::instance()->getUISceneNode(); + std::string html; + ASSERT_TRUE( FileSystem::fileGet( "assets/html/ss64.html", html ) ); + sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( html ) ); + sceneNode->updateDirtyLayouts(); + SceneManager::instance()->update(); + + auto buttons = sceneNode->getRoot()->querySelectorAll( ".tbtn" ); + ASSERT_EQ( buttons.size(), (size_t)8 ); + auto worldRect = []( UIWidget* widget ) { + Vector2f position = widget->getPixelsPosition(); + widget->nodeToWorldTranslation( position ); + return Rectf( position, widget->getPixelsSize() ); + }; + for ( size_t i = 0; i < buttons.size(); ++i ) { + for ( size_t j = 0; j < i; ++j ) + EXPECT_FALSE( worldRect( buttons[i] ).intersect( worldRect( buttons[j] ) ) ); + } + + Engine::destroySingleton(); +} + UTEST( UIHTMLFloat, autoHorizontalMarginsCenterBlockInsideFloat ) { init_float_test(); UISceneNode* sceneNode = SceneManager::instance()->getUISceneNode(); diff --git a/src/tests/unit_tests/uiscenenode_tests.cpp b/src/tests/unit_tests/uiscenenode_tests.cpp index 3c688fd91..aaf144b92 100644 --- a/src/tests/unit_tests/uiscenenode_tests.cpp +++ b/src/tests/unit_tests/uiscenenode_tests.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include @@ -19,6 +20,11 @@ using namespace EE::Window; using namespace EE::Scene; using namespace EE::UI; +UTEST( UISceneNode, CssPointerCursorUsesHandCursor ) { + EXPECT_EQ( Cursor::fromName( "pointer" ), Cursor::Hand ); + EXPECT_EQ( Cursor::fromName( "POINTER" ), Cursor::Hand ); +} + static UISceneNode* init_test_scene_node() { FileSystem::changeWorkingDirectory( Sys::getProcessPath() ); FontTrueType* font = FontTrueType::New( "NotoSans-Regular" ).get();