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.
This commit is contained in:
Martín Lucas Golini
2026-07-30 16:28:36 -03:00
parent 0485dc0d14
commit 246161be7e
6 changed files with 187 additions and 26 deletions
+107 -18
View File
@@ -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<RichText::FloatExclusion>& externalFloatExclusions,
std::vector<RichText::FloatExclusion>& 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> 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<const std::vector<FloatExclusion>> 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::FloatExclusion>& RichText::getLocalFloatExclusions() {
updateLayout();
return mLocalFloatExclusions;
}
Sizef RichText::getSize() {
updateLayout();
return mSize;
+25 -6
View File
@@ -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<UIHTMLWidget>()->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<std::vector<RichText::FloatExclusion>> propagatedFloats;
if ( widget->isType( UI_TYPE_HTML_WIDGET ) ) {
auto* htmlWidget = widget->asType<UIHTMLWidget>();
if ( !htmlWidget->establishesBlockFormattingContext() &&
htmlWidget->getRichTextPtr() ) {
const auto& localFloats =
htmlWidget->getRichTextPtr()->getLocalFloatExclusions();
if ( !localFloats.empty() ) {
propagatedFloats =
std::make_shared<std::vector<RichText::FloatExclusion>>(
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<UITextSpan>()->isInlineBlock() &&
+1 -1
View File
@@ -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;
@@ -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<UISvg>();
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<UIRichText>();
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();
@@ -10,6 +10,7 @@
#include <eepp/ui/uiscenenode.hpp>
#include <eepp/ui/uithememanager.hpp>
#include <eepp/ui/uiwidget.hpp>
#include <eepp/window/cursor.hpp>
#include <eepp/window/engine.hpp>
#include <eepp/window/input.hpp>
@@ -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();