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.
This commit is contained in:
Martín Lucas Golini
2026-08-01 16:05:05 -03:00
parent da4d201eeb
commit 6d70183da6
5 changed files with 126 additions and 8 deletions
+8 -7
View File
@@ -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<RichText::FloatExclusion>& externalFloatExclusions,
std::vector<RichText::FloatExclusion>& localFloatExclusions,
bool lineWrap, RichText::WhiteSpaceWrapMode whiteSpaceWrapMode ) {
std::vector<RichText::FloatExclusion>& 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 ) );
+16
View File
@@ -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 ) |
+12
View File
@@ -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 <span>)
// needs an independent block formatting context instead of InlineLayouter.
if ( container->isType( UI_TYPE_HTML_WIDGET ) &&
container->asType<UIHTMLWidget>()->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:
+4 -1
View File
@@ -1707,6 +1707,9 @@ void UIRichText::rebuildRichText( UILayout* container, RichText& richText, Intri
Float maxWidth = 0;
bool isInlineBlockTextSpan =
container->isType( UI_TYPE_TEXTSPAN ) && container->asType<UITextSpan>()->isInlineBlock();
bool isShrinkToFitFloat = container->isType( UI_TYPE_HTML_WIDGET ) &&
container->asType<UIHTMLWidget>()->getCSSFloat() != CSSFloat::None &&
container->getLayoutWidthPolicy() == SizePolicy::WrapContent;
Node* parentNode = container->getParent();
bool parentIsFlexOrGrid = parentNode && parentNode->isType( UI_TYPE_HTML_WIDGET ) &&
( parentNode->asType<UIHTMLWidget>()->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 &&
@@ -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(
<body style="margin:0">
<div id="link" style="width:800px">
<p></p>
<span id="rank" style="float:left;margin-top:15px;overflow:hidden;
font:16px arial;text-align:right">25</span>
<div id="midcol" style="float:left;margin:0 7px;overflow:hidden;
font-size:13px;font-weight:bold">
<div id="up" style="display:block;width:15px;height:14px;
margin:2px auto 0"></div>
<div style="text-align:center">&#8226;</div>
<div id="down" style="display:block;width:15px;height:14px;
margin:2px auto 0"></div>
</div>
</div>
<span id="flair" style="display:inline-block;height:16px;line-height:16px;
overflow:hidden;padding:0 4px">
<span>Humor </span><span id="emoji" style="display:inline-block;width:15px;
height:15px;vertical-align:middle"></span>
</span>
</body>
)html" ) );
sceneNode->updateDirtyLayouts();
auto* link = sceneNode->find<UIWidget>( "link" );
auto* rank = sceneNode->find<UIWidget>( "rank" );
auto* midcol = sceneNode->find<UIWidget>( "midcol" );
auto* up = sceneNode->find<UIWidget>( "up" );
auto* down = sceneNode->find<UIWidget>( "down" );
auto* flair = sceneNode->find<UIWidget>( "flair" );
auto* emoji = sceneNode->find<UIWidget>( "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<UIRichText>()->getRichTextPtr()->getSize().getWidth(), 0.f );
EXPECT_FALSE( rank->asType<UIRichText>()->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();