diff --git a/bin/unit_tests/assets/html/grid_size_miscalculation.html b/bin/unit_tests/assets/html/grid_size_miscalculation.html
new file mode 100644
index 000000000..f039726f6
--- /dev/null
+++ b/bin/unit_tests/assets/html/grid_size_miscalculation.html
@@ -0,0 +1,138 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/include/eepp/ui/uirichtext.hpp b/include/eepp/ui/uirichtext.hpp
index 05e81727a..a0e329d96 100644
--- a/include/eepp/ui/uirichtext.hpp
+++ b/include/eepp/ui/uirichtext.hpp
@@ -245,6 +245,7 @@ class EE_API UIHTMLBody : public UIRichText {
protected:
bool mPropagatedBackground{ false };
StyleSheetLength mMinHeightLocal;
+ bool mSettingBodyHeight{ false };
UIHTMLBody( const std::string& tag = "body" );
};
diff --git a/src/eepp/ui/blocklayouter.cpp b/src/eepp/ui/blocklayouter.cpp
index b392cb3f6..ff11b3876 100644
--- a/src/eepp/ui/blocklayouter.cpp
+++ b/src/eepp/ui/blocklayouter.cpp
@@ -77,8 +77,16 @@ void BlockLayouter::updateLayout() {
Node* parentNode = widget->getParent();
bool parentIsFlex = parentNode && parentNode->isType( UI_TYPE_HTML_WIDGET ) &&
parentNode->asType()->isFlex();
+ bool parentIsGrid = parentNode && parentNode->isType( UI_TYPE_HTML_WIDGET ) &&
+ parentNode->asType()->isGrid();
- if ( widget->isInline() && !parentIsFlex )
+ // Inline elements normally do not run their own block layout; the nearest parent RichText
+ // stream formats and paints them. Flex/grid containers are the exception: CSS blockification
+ // turns each child into an independent item, so an inline UITextSpan inside any flex/grid
+ // parent, including inline-flex/inline-grid, still needs this layouter to build its own
+ // RichText, measure its height, and position text. This is broader than the render ownership
+ // guard in UIRichText/UITextSpan, which only applies to block-level flex/grid containers.
+ if ( widget->isInline() && !parentIsFlex && !parentIsGrid )
return;
mResizedCount = 0;
diff --git a/src/eepp/ui/gridlayouter.cpp b/src/eepp/ui/gridlayouter.cpp
index 47d4534b3..9ea338749 100644
--- a/src/eepp/ui/gridlayouter.cpp
+++ b/src/eepp/ui/gridlayouter.cpp
@@ -594,12 +594,21 @@ void GridLayouter::sizeTracksForAxis( bool isColumns ) {
bool hasDefiniteSize = mContainer->getLayoutWidthPolicy() != SizePolicy::WrapContent;
if ( !isColumns )
hasDefiniteSize = mContainer->getLayoutHeightPolicy() != SizePolicy::WrapContent;
+ CSS::PropertyRelativeTarget relativeTarget =
+ isColumns ? CSS::PropertyRelativeTarget::ContainingBlockWidth
+ : CSS::PropertyRelativeTarget::ContainingBlockHeight;
+ auto resolveLength = [&]( const GridTrackBreadth& breadth ) {
+ // GridTrackParser keeps the raw CSS token so sizing can resolve units with the container
+ // context. Do not use breadth.value directly for Length tracks: tokens such as "6.5rem"
+ // parse to numeric 6.5 but must become CSS pixels before track sizing and item wrapping.
+ return mContainer->lengthFromValue( breadth.raw, relativeTarget, breadth.value );
+ };
// Initialize base sizes
for ( auto& track : tracks ) {
switch ( track.definition.min.type ) {
case GridTrackBreadthType::Length:
- track.baseSize = track.definition.min.value;
+ track.baseSize = resolveLength( track.definition.min );
break;
case GridTrackBreadthType::Percentage:
if ( hasDefiniteSize )
@@ -612,11 +621,11 @@ void GridLayouter::sizeTracksForAxis( bool isColumns ) {
break;
}
if ( track.definition.max.type == GridTrackBreadthType::Length )
- track.growthLimit = track.definition.max.value;
+ track.growthLimit = resolveLength( track.definition.max );
else if ( track.definition.max.type == GridTrackBreadthType::Percentage && hasDefiniteSize )
track.growthLimit = track.definition.max.value * contentBoxSize / 100.f;
else if ( track.definition.max.type == GridTrackBreadthType::FitContent )
- track.growthLimit = track.definition.max.value;
+ track.growthLimit = resolveLength( track.definition.max );
else
track.growthLimit = std::numeric_limits::infinity();
}
diff --git a/src/eepp/ui/uihtmlwidget.cpp b/src/eepp/ui/uihtmlwidget.cpp
index 04f3437be..234bfc0cd 100644
--- a/src/eepp/ui/uihtmlwidget.cpp
+++ b/src/eepp/ui/uihtmlwidget.cpp
@@ -982,6 +982,29 @@ void UIHTMLWidget::updateOutOfFlowPosition() {
bool useLeft = mLeftEq != "auto";
bool useRight = mRightEq != "auto";
+ // Per CSS §10.1: for absolutely positioned elements, percentage top/bottom
+ // resolves against the containing block's height. If the containing block
+ // does not have a definite height, the percentage computes to auto to
+ // prevent circular dependencies.
+ auto cbHasDefiniteHeight = [&]() {
+ if ( !cb->isLayout() )
+ return true;
+ auto* cbLayout = cb->asType();
+ if ( cbLayout->getLayoutHeightPolicy() != SizePolicy::Fixed )
+ return false;
+ if ( cb->getUIStyle() ) {
+ const auto* hprop = cb->getUIStyle()->getProperty( PropertyId::Height );
+ if ( hprop && StyleSheetLength::isPercentage( hprop->value() ) )
+ return false;
+ }
+ return true;
+ };
+
+ if ( useTop && StyleSheetLength::isPercentage( mTopEq ) && !cbHasDefiniteHeight() )
+ useTop = false;
+ if ( useBottom && StyleSheetLength::isPercentage( mBottomEq ) && !cbHasDefiniteHeight() )
+ useBottom = false;
+
if ( useLeft )
left = lengthFromValue( mLeftEq, CSS::PropertyRelativeTarget::ContainingBlockWidth, 0 );
if ( useRight )
diff --git a/src/eepp/ui/uirichtext.cpp b/src/eepp/ui/uirichtext.cpp
index e2118f099..1ccb97437 100644
--- a/src/eepp/ui/uirichtext.cpp
+++ b/src/eepp/ui/uirichtext.cpp
@@ -248,7 +248,9 @@ bool UIHTMLBody::applyProperty( const StyleSheetProperty& attribute ) {
void UIHTMLBody::updateLayout() {
UIRichText::updateLayout();
- if ( mChild && mChild->isWidget() ) {
+ if ( mChild && mChild->isWidget() && !mSettingBodyHeight ) {
+ mSettingBodyHeight = true;
+
Float maxH = 0;
Node* child = mChild;
Float minHeight = std::max(
@@ -279,6 +281,8 @@ void UIHTMLBody::updateLayout() {
if ( dpH != minHeight )
setMinHeight( dpH );
}
+
+ mSettingBodyHeight = false;
}
}
@@ -371,6 +375,18 @@ void UIRichText::draw() {
if ( mVisible && 0.f != mAlpha ) {
UIWidget::draw();
+ // Block-level flex and grid containers do not own text painting for their item children.
+ // Their children are blockified and laid out/drawn as independent nodes. A container
+ // can still have a stale mRichText from a previous display state or an intermediate
+ // layout pass, so bail out here too; otherwise grid/flex items are painted twice: once by
+ // this parent stream and once by the child UITextSpan::draw().
+ //
+ // Do not use isFlex()/isGrid() here: those also include inline-flex/inline-grid. Inline
+ // flex/grid containers are atomic inline-level boxes in their parent formatting context
+ // and must not be treated like block-level containers for this paint-ownership shortcut.
+ if ( getDisplay() == CSSDisplay::Flex || getDisplay() == CSSDisplay::Grid )
+ return;
+
if ( mRichText.getSize().getWidth() > 0.f ) {
Rectf contentOffset = getPixelsContentOffset();
if ( isClipped() ) {
@@ -1604,11 +1620,7 @@ void UIRichText::rebuildRichText( UILayout* container, RichText& richText, Intri
} else if ( parentIsFlexOrGrid &&
container->getLayoutWidthPolicy() == SizePolicy::WrapContent &&
mode == IntrinsicMode::None ) {
- maxWidth = container->getPixelsSize().getWidth() > 0
- ? container->getPixelsSize().getWidth() -
- container->getPixelsContentOffset().Left -
- container->getPixelsContentOffset().Right
- : 0;
+ maxWidth = 0;
} else if ( container->getLayoutWidthPolicy() == SizePolicy::WrapContent ) {
maxWidth = container->getMatchParentWidth() - container->getPixelsContentOffset().Left -
container->getPixelsContentOffset().Right;
@@ -2032,6 +2044,22 @@ void UIRichText::rebuildRichText( UILayout* container, RichText& richText, Intri
}
};
+ if ( container->isType( UI_TYPE_HTML_WIDGET ) ) {
+ auto* htmlContainer = container->asType();
+ // RichText is the inline-formatting stream for normal block containers. Block-level
+ // flex/grid containers are different: every in-flow child becomes a flex/grid item and
+ // must not be folded into the parent's text stream. Keeping the parent stream empty
+ // prevents duplicate paint and keeps item geometry sourced from the layouter, not from
+ // inline fragments.
+ //
+ // Inline-flex/inline-grid are intentionally excluded here for the same reason as in
+ // UIRichText::draw(): isFlex()/isGrid() includes inline display variants, but these
+ // ownership exits are only valid for block-level flex/grid containers.
+ CSSDisplay display = htmlContainer->getDisplay();
+ if ( display == CSSDisplay::Flex || display == CSSDisplay::Grid )
+ return;
+ }
+
Node* child = container->getFirstChild();
while ( NULL != child ) {
bool isOutOfFlow =
diff --git a/src/eepp/ui/uitextspan.cpp b/src/eepp/ui/uitextspan.cpp
index f27fd5b2b..acd2b2a01 100644
--- a/src/eepp/ui/uitextspan.cpp
+++ b/src/eepp/ui/uitextspan.cpp
@@ -90,12 +90,24 @@ void UITextSpan::onDisplayChange() {
}
void UITextSpan::draw() {
- // When a UITextSpan is a flex item it is laid out independently by the
- // flex container (blockification per CSS Flexbox §4). In that case the
- // parent flex container does NOT render its text via rebuildRichText(),
- // so the span must draw itself.
- if ( !isInline() || ( getParent() && getParent()->isType( UI_TYPE_HTML_WIDGET ) &&
- getParent()->asType()->isFlex() ) ) {
+ bool parentIsBlockFlexOrGrid = false;
+ if ( getParent() && getParent()->isType( UI_TYPE_HTML_WIDGET ) ) {
+ CSSDisplay parentDisplay = getParent()->asType()->getDisplay();
+ parentIsBlockFlexOrGrid =
+ parentDisplay == CSSDisplay::Flex || parentDisplay == CSSDisplay::Grid;
+ }
+
+ // When a UITextSpan is an item in a block-level flex/grid container, it is laid out
+ // independently by the container (blockification per CSS Flexbox §4 / CSS Grid §6). In that
+ // case the parent container must not render its text via rebuildRichText(), so the span must
+ // draw itself. This must stay in sync with UIRichText::draw() and
+ // UIRichText::rebuildRichText(): block-level parent flex/grid containers skip their own text
+ // stream, and item spans are the single paint owner for their text.
+ //
+ // Do not use UIHTMLWidget::isFlex()/isGrid() for the parent test. Those include inline-flex
+ // and inline-grid, which are atomic inline-level boxes and do not use this self-paint ownership
+ // rule.
+ if ( !isInline() || parentIsBlockFlexOrGrid ) {
UIRichText::draw();
}
}
diff --git a/src/tests/unit_tests/uihtml_grid_test.cpp b/src/tests/unit_tests/uihtml_grid_test.cpp
index c42bc6f61..d91805472 100644
--- a/src/tests/unit_tests/uihtml_grid_test.cpp
+++ b/src/tests/unit_tests/uihtml_grid_test.cpp
@@ -2,6 +2,7 @@
#include
#include
+#include
#include
#include
#include
@@ -36,6 +37,25 @@ static void init_grid_test() {
themeManager->applyDefaultTheme( sceneNode->getRoot() );
}
+static size_t countBrightPixelsInRect( Image& image, const Rectf& rect ) {
+ int left = eeclamp( static_cast( rect.Left ), 0, static_cast( image.getWidth() ) );
+ int top = eeclamp( static_cast( rect.Top ), 0, static_cast( image.getHeight() ) );
+ int right = eeclamp( static_cast( rect.Right ), 0, static_cast( image.getWidth() ) );
+ int bottom =
+ eeclamp( static_cast( rect.Bottom ), 0, static_cast( image.getHeight() ) );
+ size_t count = 0;
+ for ( int y = top; y < bottom; ++y ) {
+ for ( int x = left; x < right; ++x ) {
+ Color color = image.getPixel( x, y );
+ if ( static_cast( color.r ) + static_cast( color.g ) +
+ static_cast( color.b ) >
+ 560 )
+ count++;
+ }
+ }
+ return count;
+}
+
// ─────────────────────────────────────────────────────────────────────────────
// Phase 0: Display routing and property defaults
// ─────────────────────────────────────────────────────────────────────────────
@@ -1828,6 +1848,80 @@ UTEST( GridContainer, gridTestFixturePlacesSpanningItems ) {
Engine::destroySingleton();
}
+UTEST( GridContainer, gridSpanItemsKeepSaneHeightAndDrawText ) {
+ auto* win = Engine::instance()->createWindow(
+ WindowSettings( 1024, 650, "UIHTML Grid Span Text Test", WindowStyle::Default,
+ WindowBackend::Default, 32, {}, 1, false, true ),
+ ContextSettings() );
+ init_grid_test();
+ UISceneNode* sceneNode = SceneManager::instance()->getUISceneNode();
+ sceneNode->setURI( "file://" + Sys::getProcessPath() + "assets/html/" );
+
+ std::string htmlContent;
+ ASSERT_TRUE( FileSystem::fileGet( "assets/html/grid_size_miscalculation.html", htmlContent ) );
+ sceneNode->loadLayoutFromString( EE::UI::Tools::HTMLFormatter::HTMLtoXML( htmlContent ) );
+ sceneNode->update( Seconds( 1 ) );
+ sceneNode->updateDirtyLayouts();
+
+ auto* headerBox = sceneNode->getRoot()->findByClass( "header-box" );
+ ASSERT_TRUE( nullptr != headerBox );
+ ASSERT_TRUE( headerBox->isWidget() );
+
+ auto rows = headerBox->findAllByClass( "hb-row" );
+ auto keys = headerBox->findAllByClass( "hb-k" );
+ auto values = headerBox->findAllByClass( "hb-v" );
+ ASSERT_EQ( rows.size(), 3u );
+ ASSERT_EQ( keys.size(), 3u );
+ ASSERT_EQ( values.size(), 3u );
+
+ for ( auto* row : rows ) {
+ ASSERT_TRUE( row->isWidget() );
+ ASSERT_TRUE( row->isType( UI_TYPE_RICHTEXT ) );
+ EXPECT_GT( row->getPixelsSize().getHeight(), 10.f );
+ EXPECT_LT( row->getPixelsSize().getHeight(), 60.f );
+ }
+
+ for ( auto* span : keys ) {
+ ASSERT_TRUE( span->isType( UI_TYPE_TEXTSPAN ) );
+ auto* richText = span->asType()->getRichTextPtr();
+ ASSERT_TRUE( richText != nullptr );
+ EXPECT_EQ( richText->getLines().size(), 1u );
+ EXPECT_GT( span->getPixelsSize().getHeight(), 10.f );
+ EXPECT_LT( span->getPixelsSize().getHeight(), 60.f );
+ }
+
+ for ( auto* span : values ) {
+ ASSERT_TRUE( span->isType( UI_TYPE_TEXTSPAN ) );
+ auto* richText = span->asType()->getRichTextPtr();
+ ASSERT_TRUE( richText != nullptr );
+ EXPECT_GE( richText->getLines().size(), 1u );
+ EXPECT_GT( span->getPixelsSize().getHeight(), 10.f );
+ EXPECT_LT( span->getPixelsSize().getHeight(), 60.f );
+ }
+
+ EXPECT_GT( headerBox->asType()->getPixelsSize().getHeight(), 60.f );
+ EXPECT_LT( headerBox->asType()->getPixelsSize().getHeight(), 160.f );
+
+ win->setClearColor( Color( "#0c1520" ) );
+ win->clear();
+ SceneManager::instance()->draw();
+ win->display();
+
+ Image image = win->getFrontBufferImage();
+ size_t drawnTextPixels = 0;
+ for ( auto* span : values ) {
+ Rectf rect = span->getScreenRect();
+ drawnTextPixels += countBrightPixelsInRect( image, rect );
+ rect.Top = image.getHeight() - span->getScreenRect().Bottom;
+ rect.Bottom = image.getHeight() - span->getScreenRect().Top;
+ drawnTextPixels += countBrightPixelsInRect( image, rect );
+ }
+ EXPECT_GT( drawnTextPixels, 20u );
+ EXPECT_LT( drawnTextPixels, 10000u );
+
+ Engine::destroySingleton();
+}
+
UTEST( GridContainer, newsblurReducedGrid ) {
Engine::instance()->createWindow( WindowSettings( 1024, 650, "UIHTML Grid NewsBlur Test",
WindowStyle::Default, WindowBackend::Default,
diff --git a/src/tests/unit_tests/uihtml_tests.cpp b/src/tests/unit_tests/uihtml_tests.cpp
index 44896e3ce..8e05ffcea 100644
--- a/src/tests/unit_tests/uihtml_tests.cpp
+++ b/src/tests/unit_tests/uihtml_tests.cpp
@@ -3682,6 +3682,13 @@ UTEST( UIHTML, FlexFormLayout ) {
Float inputRight = inputWidget->getPixelsPosition().x + inputWidget->getPixelsSize().getWidth();
EXPECT_GT( buttonWidget->getPixelsPosition().x, inputRight - 1.f );
+ auto* buttonRichText = buttonWidget->isType( UI_TYPE_RICHTEXT )
+ ? buttonWidget->asType()->getRichTextPtr()
+ : nullptr;
+ ASSERT_TRUE( buttonRichText != nullptr );
+ buttonRichText->updateLayout();
+ EXPECT_EQ( buttonRichText->getLines().size(), 1u );
+
Engine::destroySingleton();
}
@@ -3783,6 +3790,19 @@ UTEST( UIHTML, FlexMediaQueriesLayout ) {
EXPECT_GT( essayNavLinkWidget->getPixelsSize().getHeight(),
labelWidget->getPixelsSize().getHeight() +
titleWidget->getPixelsSize().getHeight() - 1.f );
+
+ auto* labelRichText = essayLabel->isType( UI_TYPE_TEXTSPAN )
+ ? essayLabel->asType()->getRichTextPtr()
+ : nullptr;
+ auto* titleRichText = essayTitle->isType( UI_TYPE_TEXTSPAN )
+ ? essayTitle->asType()->getRichTextPtr()
+ : nullptr;
+ ASSERT_TRUE( labelRichText != nullptr );
+ ASSERT_TRUE( titleRichText != nullptr );
+ labelRichText->updateLayout();
+ titleRichText->updateLayout();
+ EXPECT_EQ( labelRichText->getLines().size(), 1u );
+ EXPECT_EQ( titleRichText->getLines().size(), 1u );
}
Engine::destroySingleton();