diff --git a/include/eepp/ui/uilayout.hpp b/include/eepp/ui/uilayout.hpp index 4beea375b..186f88781 100644 --- a/include/eepp/ui/uilayout.hpp +++ b/include/eepp/ui/uilayout.hpp @@ -9,6 +9,15 @@ class GridLayouter; class EE_API UILayout : public UIWidget { public: + struct Metrics { + Uint64 autoSizeChildren{ 0 }; + Uint64 childCountChanges{ 0 }; + Uint64 invalidations{ 0 }; + Uint64 richTextRebuilds{ 0 }; + Uint64 synchronousUpdates{ 0 }; + Uint64 treeUpdates{ 0 }; + }; + virtual Uint32 getType() const; virtual bool isType( const Uint32& type ) const; @@ -29,6 +38,14 @@ class EE_API UILayout : public UIWidget { void setLayoutDirty(); + static void resetMetrics(); + + static Metrics getMetrics(); + + static void setMetricsEnabled( bool enabled ); + + static void countRichTextRebuild(); + protected: friend class GridLayouter; friend class UISceneNode; @@ -36,6 +53,11 @@ class EE_API UILayout : public UIWidget { UnorderedSet mLayouts; bool mDirtyLayout{ false }; + // True only while updateLayoutTree() is actively updating this layout and walking its + // descendants. This is intentionally narrower than mDirtyLayout: code that receives layout + // notifications can use it to distinguish "already scheduled for a future pass" from + // "currently rebuilding/positioning children, so re-entering now would duplicate work." + bool mUpdatingLayoutTree{ false }; bool mPacking{ false }; bool mGravityOwner{ false }; diff --git a/include/eepp/ui/uiscenenode.hpp b/include/eepp/ui/uiscenenode.hpp index 0f130e965..01b3af5a4 100644 --- a/include/eepp/ui/uiscenenode.hpp +++ b/include/eepp/ui/uiscenenode.hpp @@ -787,10 +787,11 @@ class EE_API UISceneNode : public SceneNode { UnorderedSet mDirtyStyleState; UnorderedMap mDirtyStyleStateCSSAnimations; UnorderedSet mDirtyLayouts; + SmallVector mDirtyLayoutsSnapshot; std::vector> mTimes; ColorSchemePreference mColorSchemePreference{ ColorSchemePreference::Dark }; ContrastPreference mContrastPreference{ ContrastPreference::NoPreference }; - Uint32 mMaxInvalidationDepth{ 2 }; + Uint32 mMaxInvalidationDepth{ 3 }; Node* mCurParent{ nullptr }; Uint32 mCurOnSizeChangeListener{ 0 }; Uint32 mCurrentMarker{ 0 }; diff --git a/src/benchmarks/inline_layout_benchmark.cpp b/src/benchmarks/inline_layout_benchmark.cpp index 44a994a37..f8fa3a409 100644 --- a/src/benchmarks/inline_layout_benchmark.cpp +++ b/src/benchmarks/inline_layout_benchmark.cpp @@ -3,20 +3,39 @@ #include #include #include +#include #include #include #include +#include +#include +#include +#include #include +#include + using namespace EE; using namespace EE::Graphics; +using namespace EE::Scene; +using namespace EE::UI; using namespace EE::Window; static constexpr int numBoxes = 100; static constexpr int numSpansPerBox = 20; static constexpr int layoutIterations = 50; +static constexpr int markdownFlushIterations = 1; static constexpr Float maxWidth = 800; +static int getMarkdownFlushIterations() { + if ( const char* env = std::getenv( "EE_MARKDOWN_BENCH_FLUSH_ITERATIONS" ) ) { + Int32 val = markdownFlushIterations; + if ( String::fromString( val, std::string( env ) ) ) + return eemax( 0, val ); + } + return markdownFlushIterations; +} + UTEST( Benchmark, InlineLayout ) { Engine::instance()->createWindow( WindowSettings( 800, 600, "bench", WindowStyle::Default, WindowBackend::Default, 32, {}, 1, false, true ) ); @@ -81,3 +100,105 @@ UTEST( Benchmark, InlineLayout ) { Engine::destroySingleton(); } + +UTEST( Benchmark, MarkdownReadme ) { + FileSystem::changeWorkingDirectory( Sys::getProcessPath() ); + + const std::string readmePath = "../../README.md"; + std::string markdown; + if ( !FileSystem::fileGet( readmePath, markdown ) ) { + UTEST_PRINT_INFO( + String::format( "Failed to load %s from cwd '%s', skipping benchmark", + readmePath.c_str(), FileSystem::getCurrentWorkingDirectory().c_str() ) + .c_str() ); + return; + } + + EE::Window::Window* window = Engine::instance()->createWindow( + WindowSettings( 1280, 720, "markdown bench", WindowStyle::Default, WindowBackend::Default, + 32, {}, 1, false, true ) ); + if ( !window || !window->isOpen() ) { + Engine::destroySingleton(); + UTEST_PRINT_INFO( "Failed to create window, skipping benchmark" ); + return; + } + Engine::instance()->disableSharedGLContext(); + + PixelDensity::setPixelDensity( 1.f ); + UISceneNode* ui = UISceneNode::New( window ); + SceneManager::instance()->add( ui ); + + FontTrueType* font = FontTrueType::New( "NotoSans-Regular" ); + font->loadFromFile( "../assets/fonts/NotoSans-Regular.ttf" ); + FontTrueType* monoFont = FontTrueType::New( "monospace" ); + monoFont->loadFromFile( "../assets/fonts/DejaVuSansMono.ttf" ); + if ( !font->loaded() || !monoFont->loaded() ) { + Engine::destroySingleton(); + UTEST_PRINT_INFO( "Failed to load fonts, skipping benchmark" ); + return; + } + monoFont->setEnableDynamicMonospace( true ); + FontFamily::loadFromRegular( font ); + FontFamily::loadFromRegular( monoFont ); + ui->getUIThemeManager()->setDefaultFont( font ); + + UIScrollView* scrollView = UIScrollView::New(); + scrollView->setLayoutSizePolicy( SizePolicy::MatchParent, SizePolicy::MatchParent ); + scrollView->setParent( ui->getRoot() ); + + UIMarkdownView* markdownView = UIMarkdownView::New(); + markdownView->setLayoutSizePolicy( SizePolicy::MatchParent, SizePolicy::WrapContent ); + markdownView->setPadding( Rectf( 16, 16, 16, 16 ) ); + markdownView->setParent( scrollView->getContainer() ); + + UILayout::resetMetrics(); + + Clock loadClock; + markdownView->loadFromString( markdown ); + Time loadElapsed = loadClock.getElapsedTime(); + UILayout::Metrics afterLoad = UILayout::getMetrics(); + + int flushIterations = getMarkdownFlushIterations(); + Clock updateClock; + for ( int i = 0; i < flushIterations; ++i ) + ui->update( Seconds( 0 ) ); + Time updateElapsed = updateClock.getElapsedTime(); + UILayout::Metrics afterUpdate = UILayout::getMetrics(); + + if ( flushIterations > 0 ) + EXPECT_GT( markdownView->getPixelsSize().getHeight(), 0.f ); + + UTEST_PRINT_INFO( String::format( "README path: %s", readmePath.c_str() ).c_str() ); + UTEST_PRINT_INFO( String::format( "README bytes: %zu", markdown.size() ).c_str() ); + UTEST_PRINT_INFO( String::format( "Flush iterations: %d", flushIterations ).c_str() ); + UTEST_PRINT_INFO( String::format( "Load: %s", loadElapsed.toString().c_str() ).c_str() ); + UTEST_PRINT_INFO( + String::format( "Layout flush: %s", updateElapsed.toString().c_str() ).c_str() ); + UTEST_PRINT_INFO( + String::format( "After load - child changes: %llu, auto-size children: %llu, rich text " + "rebuilds: %llu, invalidations: %llu, sync updates: %llu, tree updates: " + "%llu", + (unsigned long long)afterLoad.childCountChanges, + (unsigned long long)afterLoad.autoSizeChildren, + (unsigned long long)afterLoad.richTextRebuilds, + (unsigned long long)afterLoad.invalidations, + (unsigned long long)afterLoad.synchronousUpdates, + (unsigned long long)afterLoad.treeUpdates ) + .c_str() ); + UTEST_PRINT_INFO( + String::format( "After flush - child changes: %llu, auto-size children: %llu, rich text " + "rebuilds: %llu, invalidations: %llu, sync updates: %llu, tree updates: " + "%llu", + (unsigned long long)afterUpdate.childCountChanges, + (unsigned long long)afterUpdate.autoSizeChildren, + (unsigned long long)afterUpdate.richTextRebuilds, + (unsigned long long)afterUpdate.invalidations, + (unsigned long long)afterUpdate.synchronousUpdates, + (unsigned long long)afterUpdate.treeUpdates ) + .c_str() ); + + UILayout::setMetricsEnabled( false ); + SceneManager::instance()->remove( ui ); + eeDelete( ui ); + Engine::destroySingleton(); +} diff --git a/src/eepp/ui/uilayout.cpp b/src/eepp/ui/uilayout.cpp index 36fb1e2f2..c57562197 100644 --- a/src/eepp/ui/uilayout.cpp +++ b/src/eepp/ui/uilayout.cpp @@ -3,12 +3,18 @@ namespace EE { namespace UI { +static UILayout::Metrics sMetrics; +static bool sMetricsEnabled{ false }; + UILayout::UILayout( const std::string& tag ) : UIWidget( tag ) { mNodeFlags |= NODE_FLAG_LAYOUT; unsetFlags( UI_TAB_FOCUSABLE ); } void UILayout::onChildCountChange( Node* child, const bool& removed ) { + if ( sMetricsEnabled ) + sMetrics.childCountChanges++; + UIWidget::onChildCountChange( child, removed ); if ( child->isLayout() ) { @@ -63,6 +69,9 @@ void UILayout::updateLayout() {} void UILayout::setLayoutDirty() { if ( !mDirtyLayout ) { + if ( sMetricsEnabled ) + sMetrics.invalidations++; + mUISceneNode->invalidateLayout( this ); mDirtyLayout = true; } @@ -78,19 +87,29 @@ void UILayout::setGravityOwner( bool gravityOwner ) { void UILayout::tryUpdateLayout() { if ( mUISceneNode->isUpdatingLayouts() ) { - updateLayout(); + if ( !isPacking() ) { + if ( sMetricsEnabled ) { + sMetrics.synchronousUpdates++; + } + updateLayout(); + } } else if ( !mDirtyLayout ) { setLayoutDirty(); } } void UILayout::updateLayoutTree() { + if ( sMetricsEnabled ) + sMetrics.treeUpdates++; + + mUpdatingLayoutTree = true; updateLayout(); for ( auto layout : mLayouts ) { layout->updateLayoutTree(); } + mUpdatingLayoutTree = false; onLayoutUpdate(); } @@ -138,10 +157,32 @@ void UILayout::updateLayoutWrappingContents() { } void UILayout::onAutoSizeChild( UIWidget* child ) { + if ( sMetricsEnabled ) + sMetrics.autoSizeChildren++; + if ( child->isLayout() ) { child->asType()->updateLayoutWrappingContents(); - } else + } else { child->onAutoSize(); + } +} + +void UILayout::resetMetrics() { + sMetrics = {}; + sMetricsEnabled = true; +} + +UILayout::Metrics UILayout::getMetrics() { + return sMetrics; +} + +void UILayout::setMetricsEnabled( bool enabled ) { + sMetricsEnabled = enabled; +} + +void UILayout::countRichTextRebuild() { + if ( sMetricsEnabled ) + sMetrics.richTextRebuilds++; } }} // namespace EE::UI diff --git a/src/eepp/ui/uirichtext.cpp b/src/eepp/ui/uirichtext.cpp index e0b3b70ff..0c2c09ad4 100644 --- a/src/eepp/ui/uirichtext.cpp +++ b/src/eepp/ui/uirichtext.cpp @@ -1078,18 +1078,22 @@ void UIRichText::loadFromXmlNode( const pugi::xml_node& node ) { } void UIRichText::onSizeChange() { - if ( getCSSPosition() == CSSPosition::Fixed ) { - // Fixed-position elements are sized relative to the viewport. Do not - // trigger a self-layout pass (tryUpdateLayout via - // UIHTMLWidget::onSizeChange) nor propagate size changes upward - // (notifyLayoutAttrChangeParent): neither ancestors nor this element - // need to re-layout in response to a viewport-relative size change, - // and doing so would cause unnecessary re-layout or infinite recursion. + if ( isOutOfFlow() ) { + // Out-of-flow elements are sized/positioned by updateOutOfFlowPosition(). That routine can + // apply a computed width/height after the normal layouter has run. Running + // UIHTMLWidget::onSizeChange() from here would call tryUpdateLayout() on the same element + // while updateOutOfFlowPosition() is still on the stack, which can recurse forever through + // updateLayout() -> updateOutOfFlowPosition() -> setPixelsSize() -> onSizeChange(). + // + // We still emit the generic UIWidget size-change notification so drawing/events stay + // current, and absolute elements still notify ancestors below: body-like containers may + // include absolute descendants when computing their effective content height. Fixed + // elements are excluded from the parent notification because they are viewport-relative and + // do not participate in normal-flow sizing. UIWidget::onSizeChange(); } else { UIHTMLWidget::onSizeChange(); } - notifyLayoutAttrChange(); if ( getCSSPosition() != CSSPosition::Fixed ) notifyLayoutAttrChangeParent(); } @@ -1590,6 +1594,7 @@ static Drawable* getInlineBorderDrawable( UIWidget* widget ) { } void UIRichText::rebuildRichText( UILayout* container, RichText& richText, IntrinsicMode mode ) { + UILayout::countRichTextRebuild(); richText.clear(); if ( container->isType( UI_TYPE_RICHTEXT ) || container->isType( UI_TYPE_TEXTSPAN ) ) { auto* uiRt = static_cast( container ); @@ -2170,24 +2175,58 @@ Uint32 UIRichText::onMessage( const NodeMessage* Msg ) { bool packing = isPacking(); if ( packing ) return 1; - if ( Msg->getSender() != this ) { + bool senderIsFixed = Msg->getSender()->isType( UI_TYPE_HTML_WIDGET ) && + static_cast( Msg->getSender() ) + ->getCSSPosition() == CSSPosition::Fixed; + + // updateOutOfFlowPosition() may set this element's own size while the scene is already + // updating layouts. That self size-change message has already been accounted for by + // the out-of-flow positioning routine; responding to it by relaying into tryUpdateLayout() + // re-enters updateOutOfFlowPosition() and can recurse until stack overflow. Parent + // notifications for absolute elements are handled by onSizeChange(), so ignoring this + // self-message does not hide the size change from ancestors that need it. + if ( Msg->getSender() == this && isOutOfFlow() && mUISceneNode->isUpdatingLayouts() ) + return 1; + + // A block RichText owns an inline formatting stream built from its descendants. + // During updateLayoutTree(), BlockLayouter rebuilds that stream, measures embedded + // widgets, and positions children. Those child size/position changes naturally emit + // LayoutAttributeChange messages back to this RichText. Re-entering this same block + // immediately would throw away the stream being built and rebuild it again while the + // first pass is still positioning descendants. Documents expose this as + // thousands of redundant rebuildRichText()/onAutoSizeChild() calls per frame. + // + // When mUpdatingLayoutTree is true, the current pass is already incorporating these + // descendant measurements, so the correct action is to invalidate intrinsic width + // caches and absorb the message. External changes, async image resize notifications, + // and normal post-layout mutations arrive outside this active pass and still follow the + // regular notifyLayoutAttrChangeParent()/tryUpdateLayout() path below. + if ( !senderIsFixed && mUISceneNode->isUpdatingLayouts() && mUpdatingLayoutTree && + !isOutOfFlow() ) { invalidateIntrinsicSize(); - // Fixed-position children are sized relative to the viewport - // and never affect the parent's normal-flow layout. Suppress - // ancestor notification and parent re-layout for these; absolute - // children are allowed because parents like body compute their - // minHeight from absolute children. - if ( !Msg->getSender()->isType( UI_TYPE_HTML_WIDGET ) || - static_cast( Msg->getSender() )->getCSSPosition() != - CSSPosition::Fixed ) { - notifyLayoutAttrChangeParent(); - } + return 1; } - if ( !Msg->getSender()->isType( UI_TYPE_HTML_WIDGET ) || - static_cast( Msg->getSender() )->getCSSPosition() != - CSSPosition::Fixed ) { + + if ( Msg->getSender() != this ) { + // A descendant change invalidates the inline/block formatting stream owned by this + // RichText. Do not immediately relay that invalidation to the parent: the parent's + // layout depends on this RichText's box, not on the raw descendant event. Relaying + // first lets dirty-layout coalescing replace this dirty RichText with an ancestor + // layout, and generic ancestors such as UILinearLayout measure children before the + // recursive child update runs. Delayed image loads then leave the ancestor with the + // old RichText height until an unrelated hover/resize causes another invalidation. + // + // Rebuild this RichText instead. If the rebuilt stream changes the RichText box, + // UIRichText::onSizeChange() will notify ancestors with the actual size change; if + // the box does not change, no ancestor layout work was necessary. Fixed-position + // descendants are still ignored below because they are viewport-relative and do not + // affect this normal-flow box. + invalidateIntrinsicSize(); + } + + if ( !senderIsFixed ) tryUpdateLayout(); - } + return 1; } case NodeMessage::MouseDown: { diff --git a/src/eepp/ui/uiscenenode.cpp b/src/eepp/ui/uiscenenode.cpp index 9032a4fe5..76e87feed 100644 --- a/src/eepp/ui/uiscenenode.cpp +++ b/src/eepp/ui/uiscenenode.cpp @@ -916,13 +916,34 @@ void UISceneNode::setIsLoading( bool isLoading ) { void UISceneNode::updateDirtyLayouts() { if ( !mDirtyLayouts.empty() ) { Clock clock; + + // Process a snapshot instead of iterating mDirtyLayouts directly. Layout is allowed to + // invalidate more layouts while this pass is running: size changes can happen as children + // are measured, async resources can resolve, and HTML/RichText can discover that a parent + // needs another pass. Those new invalidations must remain in mDirtyLayouts for the outer + // invalidation-depth loop to process next. If we iterate the live set and clear it at the + // end, any invalidation created during layout is silently lost; if we force everything to + // update synchronously instead, RichText/block layout can re-enter the same parent many + // times and rebuild the same inline stream repeatedly. + // + // Keep the snapshot as reusable storage instead of moving mDirtyLayouts into a temporary + // set. Moving the set transfers its bucket/node allocation and frees it at the end of this + // function, which makes large documents rebuild the dirty-set allocation on every layout + // wave. Copying only layout pointers into a SmallVector keeps common passes inline, lets + // large bursts grow once and retain capacity, and mDirtyLayouts.clear() preserves the set + // buckets for invalidations produced by the current pass. + mDirtyLayoutsSnapshot.clear(); + mDirtyLayoutsSnapshot.reserve( mDirtyLayouts.size() ); + for ( auto layout : mDirtyLayouts ) + mDirtyLayoutsSnapshot.push_back( layout ); + mDirtyLayouts.clear(); + mUpdatingLayouts = true; - for ( UILayout* layout : mDirtyLayouts ) { + for ( UILayout* layout : mDirtyLayoutsSnapshot ) { layout->updateLayoutTree(); } - mDirtyLayouts.clear(); mUpdatingLayouts = false; if ( mVerbose ) diff --git a/src/examples/ui_markdownview/ui_markdownview.cpp b/src/examples/ui_markdownview/ui_markdownview.cpp index c1ee6d024..5c0d65d64 100644 --- a/src/examples/ui_markdownview/ui_markdownview.cpp +++ b/src/examples/ui_markdownview/ui_markdownview.cpp @@ -1,14 +1,59 @@ #include -#include -EE_MAIN_FUNC int main( int, char** ) { +#include +#include + +EE_MAIN_FUNC int main( int argc, char* argv[] ) { UIApplication app( { 1280, 720, "eepp - UIMarkdownView Example" }, {}, ContextSettings( false, ContextSettings::FrameRateLimitScreenRefreshRate, 4 ) ); - app.getUI()->loadLayoutFromString( R"xml( + args::ArgumentParser parser( "eepp Markdown View Example" ); + args::HelpFlag help( parser, "help", "Display this help menu", { 'h', "help" } ); + + args::Positional uri( parser, "URI", "The local URI to load" ); + args::ValueFlag prefersColorScheme( + parser, "prefers-color-scheme", + "Set the preferred color scheme (\"light\", \"dark\" or \"system\")", + { 'c', "prefers-color-scheme" } ); + args::Flag benchmarkMode( parser, "benchmark-mode", + "Render as much as possible to measure the rendering performance.", + { "benchmark-mode" } ); + args::ValueFlag pixelDensityConf( parser, "pixel-density", + "Set default application pixel density", + { 'd', "pixel-density" } ); + + try { + parser.ParseCLI( Sys::parseArguments( argc, argv ) ); + } catch ( const args::Help& ) { + std::cout << parser; + return EXIT_SUCCESS; + } catch ( const args::ParseError& e ) { + std::cerr << e.what() << std::endl; + std::cerr << parser; + return EXIT_FAILURE; + } catch ( args::ValidationError& e ) { + std::cerr << e.what() << std::endl; + std::cerr << parser; + return EXIT_FAILURE; + } + + auto ui = app.getUI(); + ui->setColorSchemePreference( + !prefersColorScheme.Get().empty() + ? ColorSchemePreferences::fromStringExt( prefersColorScheme.Get() ) + : ColorSchemeExtPreference::Dark ); + + ui->loadLayoutFromString( R"xml( - + + + )xml" ); + + auto markdownView = ui->find( "markdown_view" ); + + if ( uri.Get().empty() ) { + markdownView->loadFromString( R"markdown( # Markdown Header 1 ## Markdown Header 2 ### Markdown Header 3 @@ -35,11 +80,17 @@ void main() { printf("Hello World"); } ``` - - - )xml" ); + )markdown" ); + } else if ( FileSystem::fileExists( uri.Get() ) ) { + std::string file( uri.Get() ); + std::string data; + FileSystem::fileGet( file, data ); + markdownView->loadFromString( data ); + } else { + std::cout << "File not found\n"; - auto markdownView = app.getUI()->find( "markdown_view" ); + return EXIT_FAILURE; + } app.getWindow()->getInput()->pushCallback( [markdownView]( InputEvent* event ) { switch ( event->Type ) { @@ -59,9 +110,9 @@ void main() { } } ); - app.getUI()->on( Event::KeyUp, [&app]( const Event* event ) { + ui->on( Event::KeyUp, [ui]( const Event* event ) { if ( event->asKeyEvent()->getKeyCode() == KEY_F11 ) { - UIWidgetInspector::create( app.getUI() ); + UIWidgetInspector::create( ui ); } } ); diff --git a/src/tests/unit_tests/uihtml_tests.cpp b/src/tests/unit_tests/uihtml_tests.cpp index a18c80a95..24d62b5c1 100644 --- a/src/tests/unit_tests/uihtml_tests.cpp +++ b/src/tests/unit_tests/uihtml_tests.cpp @@ -4,7 +4,9 @@ #include #include +#include #include +#include #include #include #include @@ -17,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -3982,6 +3985,70 @@ UTEST( UIHTML, ImagePercentageWidthRespectsParentMaxWidth ) { Engine::destroySingleton(); } +UTEST( UIHTML, TextureReplaceInvalidatesRichTextAncestors ) { + auto win = Engine::instance()->createWindow( + WindowSettings( 1024, 768, "texture replace img relayout", WindowStyle::Default, + WindowBackend::Default, 32, {}, 1, false, true ), + ContextSettings( false, 0, 0, GLv_default, true, false ) ); + + UISceneNode* sceneNode = init_test_inline_block(); + + UIWebView* webView = UIWebView::New(); + webView->setParent( sceneNode->getRoot() ); + webView->setPixelsSize( win->getWidth(), win->getHeight() ); + webView->setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::Fixed ); + std::string html = R"html( + + + +
+

Before image

+ +

After image

+
+ + + )html"; + sceneNode->setURI( "file://delayed-image-resize.html" ); + sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( html ), webView->getDocumentContainer(), + String::hash( "delayed-image-resize" ) ); + + win->getInput()->update(); + SceneManager::instance()->update(); + sceneNode->updateDirtyLayouts(); + + auto* article = sceneNode->getRoot()->find( "article" )->asType(); + auto* body = sceneNode->getRoot()->findByType( UI_TYPE_HTML_BODY )->asType(); + auto* doc = webView->getDocumentContainer(); + auto images = sceneNode->getRoot()->findAllByTag( "img" ); + ASSERT_TRUE( article != nullptr ); + ASSERT_TRUE( body != nullptr ); + ASSERT_TRUE( doc != nullptr ); + ASSERT_EQ( images.size(), (size_t)1 ); + auto* img = images[0]->asType(); + ASSERT_TRUE( img != nullptr ); + + Texture* texture = TextureFactory::instance()->createEmptyTexture( 1, 1, 4, Color::Transparent ); + ASSERT_TRUE( texture != nullptr ); + img->setDrawable( texture ); + sceneNode->updateDirtyLayouts(); + + Float articleInitialHeight = article->getPixelsSize().getHeight(); + Float bodyInitialHeight = body->getPixelsSize().getHeight(); + Float docInitialHeight = doc->getPixelsSize().getHeight(); + + Image loadedImage( 320, 180, 4, Color::White ); + texture->replace( &loadedImage ); + SceneManager::instance()->update(); + sceneNode->updateDirtyLayouts(); + + EXPECT_GT( article->getPixelsSize().getHeight(), articleInitialHeight + 150.f ); + EXPECT_GT( body->getPixelsSize().getHeight(), bodyInitialHeight + 150.f ); + EXPECT_GT( doc->getPixelsSize().getHeight(), docInitialHeight + 150.f ); + + Engine::destroySingleton(); +} + UTEST( UIHTML, ImageCSSWidthOverridesHTMLWidthAttribute ) { auto win = Engine::instance()->createWindow( WindowSettings( 1024, 768, "img css width overrides html width attr", WindowStyle::Default,