From 6d70183da667fa0d85f1d001a2ffe7656753b5a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Lucas=20Golini?= Date: Sat, 1 Aug 2026 16:05:05 -0300 Subject: [PATCH] Fix shrink-to-fit floats and atomic inline alignment Blockify floated inline elements and use wrap-content sizing for width-auto floats so vote columns do not stretch across their containing block. Recompute right-aligned text using the float's final shrink-to-fit width, preventing overflow clipping from hiding rank text. Handle bottom-edge fallback baselines in middle-aligned atomic boxes so fixed-height inline parents do not vertically displace and clip elements such as Reddit flair emoji. Add a regression test covering the Reddit rank, vote arrows, rendered text, and flair emoji alignment. --- src/eepp/graphics/richtext.cpp | 15 ++-- src/eepp/ui/uihtmlwidget.cpp | 16 ++++ src/eepp/ui/uilayoutermanager.cpp | 12 +++ src/eepp/ui/uirichtext.cpp | 5 +- src/tests/unit_tests/uihtml_float_tests.cpp | 86 +++++++++++++++++++++ 5 files changed, 126 insertions(+), 8 deletions(-) diff --git a/src/eepp/graphics/richtext.cpp b/src/eepp/graphics/richtext.cpp index 505972027..42efabf3c 100644 --- a/src/eepp/graphics/richtext.cpp +++ b/src/eepp/graphics/richtext.cpp @@ -870,8 +870,11 @@ class RichTextInlineLayouter { Float baseline = span.baseline; RichText::BaselineAlignValue baselineAlign = effectiveInlineBaselineAlign( inlineItems, span.inlinePath, span.baselineAlign ); + // Empty atomic boxes use their bottom edge as the fallback baseline. Include that + // exact edge in the middle-alignment path or fixed-height inline parents can clip + // the box after it is displaced by a second baseline formula. Float offsetY = baselineAlign.type == RichText::BaselineAlignment::Middle && - baseline > 0.f && baseline < span.size.getHeight() + baseline > 0.f && baseline <= span.size.getHeight() ? line.maxAscent - baseline : getBaselineAlignedOffset( line, span.size, baseline, span.size.getHeight(), @@ -1053,8 +1056,8 @@ class RichTextInlineLayouter { Float textIndent, Uint32 align, Float forcedLineHeight, const FontStyleConfig& defaultStyle, const std::vector& externalFloatExclusions, - std::vector& localFloatExclusions, - bool lineWrap, RichText::WhiteSpaceWrapMode whiteSpaceWrapMode ) { + std::vector& localFloatExclusions, bool lineWrap, + RichText::WhiteSpaceWrapMode whiteSpaceWrapMode ) { LayoutResult result; result.lines.push_back( RichText::RenderParagraph() ); @@ -1143,8 +1146,7 @@ class RichTextInlineLayouter { for ( ; finalizedLineCount < end; ++finalizedLineCount ) { auto& line = result.lines[finalizedLineCount]; alignLineSpans( line, 0.f, defaultStyle, forcedLineHeight, true, inlineItems ); - finalizedLinesBottom = - eemax( finalizedLinesBottom, line.y + line.height ); + finalizedLinesBottom = eemax( finalizedLinesBottom, line.y + line.height ); } }; @@ -1463,8 +1465,7 @@ class RichTextInlineLayouter { 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 } ); + localFloatExclusions.push_back( { rightFloats[i], RichText::InlineFloat::Right } ); result.size = Sizef( std::max( maxWidth, floatBoundsRight ), std::max( accumY, floatBoundsBottom ) ); diff --git a/src/eepp/ui/uihtmlwidget.cpp b/src/eepp/ui/uihtmlwidget.cpp index a748386c3..f4ad1d398 100644 --- a/src/eepp/ui/uihtmlwidget.cpp +++ b/src/eepp/ui/uihtmlwidget.cpp @@ -376,6 +376,22 @@ void UIHTMLWidget::setCSSPosition( CSSPosition position ) { void UIHTMLWidget::setCSSFloat( CSSFloat cssFloat ) { if ( mFloat != cssFloat ) { mFloat = cssFloat; + // A width:auto block normally fills its containing block, while a float uses the CSS + // shrink-to-fit width. Represent that used-width distinction with WrapContent so the + // floated box's own layouter cannot stretch it back after its parent measured it. + if ( mFloat != CSSFloat::None && getLayoutWidthPolicy() == SizePolicy::MatchParent ) + setLayoutWidthPolicy( SizePolicy::WrapContent ); + else if ( mFloat == CSSFloat::None && getLayoutWidthPolicy() == SizePolicy::WrapContent && + ( mDisplay == CSSDisplay::Block || mDisplay == CSSDisplay::ListItem ) && + ( getUIStyle() == nullptr || + getUIStyle()->getProperty( PropertyId::Width ) == nullptr ) && + mPosition != CSSPosition::Absolute && mPosition != CSSPosition::Fixed ) + setLayoutWidthPolicy( SizePolicy::MatchParent ); + + // Float changes the used display type (CSS 2.1 section 9.7), so an inline element must + // exchange InlineLayouter for BlockLayouter and vice versa when float is toggled. + eeSAFE_DELETE( mLayouter ); + getLayouter(); notifyLayoutAttrChange( toLayoutInvalidationFlags( LayoutInvalidationReason::Style ) | toLayoutInvalidationFlags( LayoutInvalidationReason::FormattingContext ) | diff --git a/src/eepp/ui/uilayoutermanager.cpp b/src/eepp/ui/uilayoutermanager.cpp index 3e1d8934c..5f4c1684c 100644 --- a/src/eepp/ui/uilayoutermanager.cpp +++ b/src/eepp/ui/uilayoutermanager.cpp @@ -36,6 +36,18 @@ UILayouter* UILayouterManager::create( CSSDisplay display, UIWidget* container ) return eeNew( BlockLayouter, ( container ) ); } + // CSS 2.1 section 9.7 blockifies floated boxes. Keep flex/grid containers using their + // respective formatting contexts, but an ordinary floated inline (for example a ) + // needs an independent block formatting context instead of InlineLayouter. + if ( container->isType( UI_TYPE_HTML_WIDGET ) && + container->asType()->getCSSFloat() != CSSFloat::None ) { + if ( display == CSSDisplay::Flex || display == CSSDisplay::InlineFlex ) + return eeNew( FlexLayouter, ( container ) ); + if ( display == CSSDisplay::Grid || display == CSSDisplay::InlineGrid ) + return eeNew( GridLayouter, ( container ) ); + return eeNew( BlockLayouter, ( container ) ); + } + switch ( display ) { case CSSDisplay::Block: case CSSDisplay::TableCell: diff --git a/src/eepp/ui/uirichtext.cpp b/src/eepp/ui/uirichtext.cpp index 4655af567..8bb4c766a 100644 --- a/src/eepp/ui/uirichtext.cpp +++ b/src/eepp/ui/uirichtext.cpp @@ -1707,6 +1707,9 @@ void UIRichText::rebuildRichText( UILayout* container, RichText& richText, Intri Float maxWidth = 0; bool isInlineBlockTextSpan = container->isType( UI_TYPE_TEXTSPAN ) && container->asType()->isInlineBlock(); + bool isShrinkToFitFloat = container->isType( UI_TYPE_HTML_WIDGET ) && + container->asType()->getCSSFloat() != CSSFloat::None && + container->getLayoutWidthPolicy() == SizePolicy::WrapContent; Node* parentNode = container->getParent(); bool parentIsFlexOrGrid = parentNode && parentNode->isType( UI_TYPE_HTML_WIDGET ) && ( parentNode->asType()->isFlex() || @@ -1717,7 +1720,7 @@ void UIRichText::rebuildRichText( UILayout* container, RichText& richText, Intri maxWidth = container->getPixelsSize().getWidth() - container->getPixelsContentOffset().Left - container->getPixelsContentOffset().Right; - } else if ( isInlineBlockTextSpan && mode == IntrinsicMode::None && + } else if ( ( isInlineBlockTextSpan || isShrinkToFitFloat ) && mode == IntrinsicMode::None && container->getLayoutWidthPolicy() == SizePolicy::WrapContent ) { maxWidth = 0; } else if ( parentIsFlexOrGrid && diff --git a/src/tests/unit_tests/uihtml_float_tests.cpp b/src/tests/unit_tests/uihtml_float_tests.cpp index 194447d71..2eb30bd85 100644 --- a/src/tests/unit_tests/uihtml_float_tests.cpp +++ b/src/tests/unit_tests/uihtml_float_tests.cpp @@ -891,6 +891,92 @@ UTEST( UIHTMLFloat, autoHorizontalMarginsCenterBlockInsideFloat ) { Engine::destroySingleton(); } +UTEST( UIHTMLFloat, autoWidthFloatsShrinkToFitRedditVoteColumn ) { + init_float_test(); + UISceneNode* sceneNode = SceneManager::instance()->getUISceneNode(); + + sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( R"html( + + + + Humor + + + )html" ) ); + sceneNode->updateDirtyLayouts(); + + auto* link = sceneNode->find( "link" ); + auto* rank = sceneNode->find( "rank" ); + auto* midcol = sceneNode->find( "midcol" ); + auto* up = sceneNode->find( "up" ); + auto* down = sceneNode->find( "down" ); + auto* flair = sceneNode->find( "flair" ); + auto* emoji = sceneNode->find( "emoji" ); + ASSERT_TRUE( link != nullptr ); + ASSERT_TRUE( rank != nullptr ); + ASSERT_TRUE( midcol != nullptr ); + ASSERT_TRUE( up != nullptr ); + ASSERT_TRUE( down != nullptr ); + ASSERT_TRUE( flair != nullptr ); + ASSERT_TRUE( emoji != nullptr ); + + const Vector2f linkPos = link->convertToWorldSpace( { 0, 0 } ); + const Vector2f rankPos = rank->convertToWorldSpace( { 0, 0 } ); + const Vector2f midcolPos = midcol->convertToWorldSpace( { 0, 0 } ); + const Vector2f upPos = up->convertToWorldSpace( { 0, 0 } ); + const Vector2f downPos = down->convertToWorldSpace( { 0, 0 } ); + + EXPECT_GT( rank->getPixelsSize().getWidth(), 0.f ); + EXPECT_GT( rank->asType()->getRichTextPtr()->getSize().getWidth(), 0.f ); + EXPECT_FALSE( rank->asType()->getRichText().getLines().empty() ); + EXPECT_LT( rank->getPixelsSize().getWidth(), 40.f ); + EXPECT_LT( midcol->getPixelsSize().getWidth(), 40.f ); + EXPECT_NEAR( rankPos.x, linkPos.x, 1.f ); + EXPECT_NEAR( midcolPos.x, rankPos.x + rank->getPixelsSize().getWidth() + 7.f, 1.f ); + EXPECT_NEAR( upPos.x + up->getPixelsSize().getWidth() / 2.f, + midcolPos.x + midcol->getPixelsSize().getWidth() / 2.f, 1.f ); + EXPECT_NEAR( downPos.x, upPos.x, 1.f ); + const Vector2f flairPos = flair->convertToWorldSpace( { 0, 0 } ); + const Vector2f emojiPos = emoji->convertToWorldSpace( { 0, 0 } ); + EXPECT_GE( emojiPos.y, flairPos.y - 0.01f ); + EXPECT_LE( emojiPos.y + emoji->getPixelsSize().getHeight(), + flairPos.y + flair->getPixelsSize().getHeight() + 0.01f ); + + auto* window = Engine::instance()->getCurrentWindow(); + window->setClearColor( Color::White ); + window->clear(); + SceneManager::instance()->draw(); + window->display(); + Image framebuffer = window->getFrontBufferImage(); + bool rankPainted = false; + for ( int y = (int)rankPos.y; y < (int)( rankPos.y + rank->getPixelsSize().getHeight() ); + ++y ) { + for ( int x = (int)rankPos.x; x < (int)( rankPos.x + rank->getPixelsSize().getWidth() ); + ++x ) { + Color pixel = framebuffer.getPixel( x, y ); + if ( pixel.r < 245 || pixel.g < 245 || pixel.b < 245 ) + rankPainted = true; + } + } + EXPECT_TRUE( rankPainted ); + + Engine::destroySingleton(); +} + UTEST( UIHTMLFloat, rightFloatConstrainsTextInsideFollowingNormalBlock ) { init_float_test(); UISceneNode* sceneNode = SceneManager::instance()->getUISceneNode();