From fa537eaaaaff62e3813c2dd7bcd971a66262cc9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Lucas=20Golini?= Date: Sun, 24 May 2026 15:29:00 -0300 Subject: [PATCH] More WIP, several HTML fixes compatibility. --- .../plans/reddit_old_thread_rendering_plan.md | 12 +- include/eepp/graphics/richtext.hpp | 7 + include/eepp/ui/css/propertydefinition.hpp | 1 + include/eepp/ui/uirichtext.hpp | 9 + src/eepp/graphics/richtext.cpp | 51 +++-- src/eepp/ui/css/stylesheetspecification.cpp | 1 + src/eepp/ui/uinodedrawable.cpp | 2 +- src/eepp/ui/uirichtext.cpp | 80 +++++++- src/eepp/ui/uiwidgetcreator.cpp | 10 +- src/tests/unit_tests/uihtml_float_tests.cpp | 194 ++++++++++++++++++ src/tests/unit_tests/uihtml_tests.cpp | 167 ++++++++++++++- 11 files changed, 496 insertions(+), 38 deletions(-) diff --git a/.agent/plans/reddit_old_thread_rendering_plan.md b/.agent/plans/reddit_old_thread_rendering_plan.md index 425de1c1e..a2a5c78be 100644 --- a/.agent/plans/reddit_old_thread_rendering_plan.md +++ b/.agent/plans/reddit_old_thread_rendering_plan.md @@ -41,7 +41,12 @@ Current progress: - The smoke test no longer loads `breeze.css`; HTML defaults now come from automatic HTML base-default injection. - Legacy `` is registered as an HTML phrasing element and receives default `line-through` styling, removing a missing-element warning from the fixture. - Horizontal `auto` margins are recomputed at RichText/block layout read points, and the old Reddit vote arrow is centered inside `.midcol` (`arrow.x=17`, `midcol.x=15`, `arrow.width=15`, `midcol.width=19` in the current smoke run). -- Remaining visible blockers: header/topbar layout is still badly overlapped, vote arrow sprite painting/background positioning still needs verification, the comment form spacing is too large, and the footer/comments vertical spacing still diverges from Chrome. +- Vote arrow sprite CSS now resolves relative to the stylesheet file, preserves negative `background-position`, and is asserted in both a reduced sprite test and the old Reddit smoke test. +- CSS `white-space` now maps browser values such as `normal`, `nowrap`, `pre`, `pre-wrap`, `pre-line`, and `break-spaces` into RichText whitespace-collapse and soft-wrap behavior. `nowrap` suppresses soft wrapping for text and atomic inline boxes, including old Reddit-style inline flat lists. +- Collapsed whitespace-only text nodes between non-inline boxes no longer create a spurious line box in RichText layout. +- HTML ` +
top row
+ + + )html" ) ); + sceneNode->updateDirtyLayouts(); + + auto* button = sceneNode->find( "button" ); + auto* bfc = sceneNode->find( "bfc" ); + ASSERT_TRUE( button != nullptr ); + ASSERT_TRUE( bfc != nullptr ); + ASSERT_TRUE( button->isType( UI_TYPE_HTML_WIDGET ) ); + EXPECT_EQ( button->asType()->getCSSFloat(), CSSFloat::Left ); + + Vector2f buttonPos = button->convertToWorldSpace( { 0, 0 } ); + Vector2f bfcPos = bfc->convertToWorldSpace( { 0, 0 } ); + + EXPECT_NEAR( buttonPos.y, bfcPos.y, 1.f ); + EXPECT_GE( bfcPos.x, buttonPos.x + button->getPixelsSize().getWidth() - 1.f ); + + Engine::destroySingleton(); +} + +UTEST( UIHTMLFloat, bfcAfterFloatOnlyLineStaysOnSameRow ) { + init_float_test(); + UISceneNode* sceneNode = SceneManager::instance()->getUISceneNode(); + + sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( R"html( + +
+ +
menu
+
top row
+
+ + )html" ) ); + sceneNode->updateDirtyLayouts(); + + auto* button = sceneNode->find( "button" ); + auto* drop = sceneNode->find( "drop" ); + auto* bfc = sceneNode->find( "bfc" ); + ASSERT_TRUE( button != nullptr ); + ASSERT_TRUE( drop != nullptr ); + ASSERT_TRUE( bfc != nullptr ); + + Vector2f buttonPos = button->convertToWorldSpace( { 0, 0 } ); + Vector2f dropPos = drop->convertToWorldSpace( { 0, 0 } ); + Vector2f bfcPos = bfc->convertToWorldSpace( { 0, 0 } ); + + EXPECT_NEAR( buttonPos.y, bfcPos.y, 1.f ); + EXPECT_NEAR( dropPos.y, bfcPos.y, 1.f ); + EXPECT_GE( bfcPos.x, dropPos.x + drop->getPixelsSize().getWidth() - 1.f ); + + Engine::destroySingleton(); +} + +UTEST( UIHTMLFloat, autoHeightBfcAfterFloatOnlyLineStaysOnSameRow ) { + init_float_test(); + UISceneNode* sceneNode = SceneManager::instance()->getUISceneNode(); + + sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( R"html( + +
+ +
menu
+
+ home - popular - all +
+
+ + )html" ) ); + sceneNode->updateDirtyLayouts(); + + auto* drop = sceneNode->find( "drop" ); + auto* bfc = sceneNode->find( "bfc" ); + ASSERT_TRUE( drop != nullptr ); + ASSERT_TRUE( bfc != nullptr ); + + Vector2f dropPos = drop->convertToWorldSpace( { 0, 0 } ); + Vector2f bfcPos = bfc->convertToWorldSpace( { 0, 0 } ); + + EXPECT_NEAR( dropPos.y, bfcPos.y, 1.f ); + EXPECT_GE( bfcPos.x, dropPos.x + drop->getPixelsSize().getWidth() - 1.f ); + + Engine::destroySingleton(); +} + +UTEST( UIHTMLFloat, inlineListBfcAfterFloatOnlyLineStaysOnSameRow ) { + init_float_test(); + UISceneNode* sceneNode = SceneManager::instance()->getUISceneNode(); + + sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( R"html( + +
+ +
menu
+
+
    +
  • home
  • +
  • - popular
  • +
  • - all
  • +
+
+
+ + )html" ) ); + sceneNode->updateDirtyLayouts(); + + auto* drop = sceneNode->find( "drop" ); + auto* bfc = sceneNode->find( "bfc" ); + ASSERT_TRUE( drop != nullptr ); + ASSERT_TRUE( bfc != nullptr ); + + Vector2f dropPos = drop->convertToWorldSpace( { 0, 0 } ); + Vector2f bfcPos = bfc->convertToWorldSpace( { 0, 0 } ); + + EXPECT_NEAR( dropPos.y, bfcPos.y, 1.f ); + EXPECT_GE( bfcPos.x, dropPos.x + drop->getPixelsSize().getWidth() - 1.f ); + + Engine::destroySingleton(); +} + +UTEST( UIHTMLFloat, absoluteInlineListBfcAfterFloatOnlyLineStaysOnSameRow ) { + init_float_test(); + UISceneNode* sceneNode = SceneManager::instance()->getUISceneNode(); + + sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( R"html( + + + + )html" ) ); + sceneNode->updateDirtyLayouts(); + + auto* drop = sceneNode->find( "drop" ); + auto* bfc = sceneNode->find( "bfc" ); + ASSERT_TRUE( drop != nullptr ); + ASSERT_TRUE( bfc != nullptr ); + + Vector2f dropPos = drop->convertToWorldSpace( { 0, 0 } ); + Vector2f bfcPos = bfc->convertToWorldSpace( { 0, 0 } ); + + EXPECT_NEAR( dropPos.y, bfcPos.y, 1.f ); + EXPECT_GE( bfcPos.x, dropPos.x + drop->getPixelsSize().getWidth() - 1.f ); + + Engine::destroySingleton(); +} + UTEST( UIHTMLFloat, autoHorizontalMarginsCenterBlockInsideFloat ) { init_float_test(); UISceneNode* sceneNode = SceneManager::instance()->getUISceneNode(); diff --git a/src/tests/unit_tests/uihtml_tests.cpp b/src/tests/unit_tests/uihtml_tests.cpp index 3622a79e2..aa91fdab9 100644 --- a/src/tests/unit_tests/uihtml_tests.cpp +++ b/src/tests/unit_tests/uihtml_tests.cpp @@ -212,12 +212,24 @@ UTEST( UIHTML, redditOldThreadWebViewSmoke ) { auto midcol = sceneNode->getRoot()->findByClass( "midcol" ); auto entry = sceneNode->getRoot()->findByClass( "entry" ); auto arrow = sceneNode->getRoot()->findByClass( "arrow" ); + auto srHeader = sceneNode->getRoot()->find( "sr-header-area" ); + auto redesignButton = sceneNode->getRoot()->find( "redesign-beta-optin-btn" ); + auto srDrop = sceneNode->getRoot()->querySelector( "#sr-header-area .dropdown.srdrop" ); + auto srList = sceneNode->getRoot()->querySelector( "#sr-header-area .sr-list" ); + auto headerBottomLeft = sceneNode->getRoot()->find( "header-bottom-left" ); + auto dropChoices = sceneNode->getRoot()->querySelector( ".drop-choices.srdrop" ); ASSERT_TRUE( side != nullptr ); ASSERT_TRUE( siteTable != nullptr ); ASSERT_TRUE( midcol != nullptr ); ASSERT_TRUE( entry != nullptr ); ASSERT_TRUE( arrow != nullptr ); + ASSERT_TRUE( srHeader != nullptr ); + ASSERT_TRUE( redesignButton != nullptr ); + ASSERT_TRUE( srDrop != nullptr ); + ASSERT_TRUE( srList != nullptr ); + ASSERT_TRUE( headerBottomLeft != nullptr ); + ASSERT_TRUE( dropChoices != nullptr ); UIWidget* content = siteTable->getParent()->isWidget() ? siteTable->getParent()->asType() : nullptr; @@ -228,6 +240,14 @@ UTEST( UIHTML, redditOldThreadWebViewSmoke ) { Vector2f midcolPos = midcol->asType()->convertToWorldSpace( { 0, 0 } ); Vector2f entryPos = entry->asType()->convertToWorldSpace( { 0, 0 } ); Vector2f arrowPos = arrow->asType()->convertToWorldSpace( { 0, 0 } ); + Vector2f srHeaderPos = srHeader->asType()->convertToWorldSpace( { 0, 0 } ); + Vector2f redesignButtonPos = + redesignButton->asType()->convertToWorldSpace( { 0, 0 } ); + Vector2f srDropPos = srDrop->asType()->convertToWorldSpace( { 0, 0 } ); + Vector2f srListPos = srList->asType()->convertToWorldSpace( { 0, 0 } ); + Vector2f headerBottomLeftPos = + headerBottomLeft->asType()->convertToWorldSpace( { 0, 0 } ); + Vector2f dropChoicesPos = dropChoices->asType()->convertToWorldSpace( { 0, 0 } ); std::cerr << "old reddit rects: " << "side=(" << sidePos.x << "," << sidePos.y << " " @@ -244,7 +264,31 @@ UTEST( UIHTML, redditOldThreadWebViewSmoke ) { << entry->asType()->getPixelsSize().getHeight() << ") " << "arrow=(" << arrowPos.x << "," << arrowPos.y << " " << arrow->asType()->getPixelsSize().getWidth() << "x" - << arrow->asType()->getPixelsSize().getHeight() << ")" << std::endl; + << arrow->asType()->getPixelsSize().getHeight() << ") " + << "srHeader=(" << srHeaderPos.x << "," << srHeaderPos.y << " " + << srHeader->asType()->getPixelsSize().getWidth() << "x" + << srHeader->asType()->getPixelsSize().getHeight() << ") " + << "redesignButton=(" << redesignButtonPos.x << "," << redesignButtonPos.y << " " + << redesignButton->asType()->getPixelsSize().getWidth() << "x" + << redesignButton->asType()->getPixelsSize().getHeight() << ") " + << "srDrop=(" << srDropPos.x << "," << srDropPos.y << " " + << srDrop->asType()->getPixelsSize().getWidth() << "x" + << srDrop->asType()->getPixelsSize().getHeight() << " float=" + << CSSFloatHelper::toString( srDrop->asType()->getCSSFloat() ) << ") " + << "srList=(" << srListPos.x << "," << srListPos.y << " " + << srList->asType()->getPixelsSize().getWidth() << "x" + << srList->asType()->getPixelsSize().getHeight() + << " lines=" << srList->asType()->getRichTextPtr()->getLines().size() + << " wrap=" << srList->asType()->getLineWrap() << ") " + << "headerBottomLeft=(" << headerBottomLeftPos.x << "," << headerBottomLeftPos.y + << " " << headerBottomLeft->asType()->getPixelsSize().getWidth() << "x" + << headerBottomLeft->asType()->getPixelsSize().getHeight() << ") " + << "dropChoices=(" << dropChoicesPos.x << "," << dropChoicesPos.y << " " + << dropChoices->asType()->getPixelsSize().getWidth() << "x" + << dropChoices->asType()->getPixelsSize().getHeight() + << " visible=" << dropChoices->asType()->isVisible() << " display=" + << CSSDisplayHelper::toString( dropChoices->asType()->getDisplay() ) + << ")" << std::endl; const Float midcolCenter = midcolPos.x + midcol->asType()->getPixelsSize().getWidth() / 2.f; @@ -252,6 +296,18 @@ UTEST( UIHTML, redditOldThreadWebViewSmoke ) { arrowPos.x + arrow->asType()->getPixelsSize().getWidth() / 2.f; EXPECT_NEAR( midcolCenter, arrowCenter, 1.f ); + auto* arrowBackground = arrow->asType()->getBackground(); + ASSERT_TRUE( arrowBackground != nullptr ); + auto* arrowBackgroundLayer = arrowBackground->getLayer( 0 ); + ASSERT_TRUE( arrowBackgroundLayer != nullptr ); + ASSERT_TRUE( arrowBackgroundLayer->getDrawable() != nullptr ); + EXPECT_EQ( arrowBackgroundLayer->getDrawable()->getPixelsSize().getWidth(), 140 ); + EXPECT_EQ( arrowBackgroundLayer->getDrawable()->getPixelsSize().getHeight(), 1751 ); + EXPECT_STDSTREQ( arrowBackgroundLayer->getPositionX(), "-42px" ); + EXPECT_STDSTREQ( arrowBackgroundLayer->getPositionY(), "-1678px" ); + EXPECT_NEAR( arrowBackgroundLayer->getOffset().x, -42.f, 0.1f ); + EXPECT_NEAR( arrowBackgroundLayer->getOffset().y, -1678.f, 0.1f ); + if ( !FileSystem::fileExists( "output" ) ) FileSystem::makeDir( "output" ); win->getFrontBufferImage().saveToFile( "output/eepp-reddit-old-thread-current.webp", @@ -275,6 +331,70 @@ UTEST( UIHTML, StrikeElementUsesDefaultLineThrough ) { Engine::destroySingleton(); } +UTEST( UIHTML, WhiteSpaceNowrapDisablesSoftWrap ) { + init_ui_test(); + UISceneNode* sceneNode = SceneManager::instance()->getUISceneNode(); + + const std::string text = "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu"; + + sceneNode->loadLayoutFromString( + HTMLFormatter::HTMLtoXML( String::format( R"html( + +
%s
+
+ %s +
+ + )html", + text.c_str(), text.c_str() ) ) ); + SceneManager::instance()->update(); + + auto* wrap = sceneNode->getRoot()->find( "wrap" )->asType(); + auto* nowrap = sceneNode->getRoot()->find( "nowrap" )->asType(); + ASSERT_TRUE( wrap != nullptr ); + ASSERT_TRUE( nowrap != nullptr ); + + EXPECT_GT( wrap->getRichTextPtr()->getLines().size(), (size_t)1 ); + EXPECT_EQ( nowrap->getRichTextPtr()->getLines().size(), (size_t)1 ); + EXPECT_FALSE( nowrap->getLineWrap() ); + + Engine::destroySingleton(); +} + +UTEST( UIHTML, WhiteSpaceNowrapKeepsInlineListOnOneLine ) { + init_ui_test(); + UISceneNode* sceneNode = SceneManager::instance()->getUISceneNode(); + + sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( R"html( + +
+
    +
  • alpha
  • +
  • beta
  • +
  • gamma
  • +
  • delta
  • +
  • epsilon
  • +
  • zeta
  • +
  • eta
  • +
  • theta
  • +
+
+ + )html" ) ); + SceneManager::instance()->update(); + + auto* bar = sceneNode->getRoot()->find( "bar" )->asType(); + auto* list = sceneNode->getRoot()->find( "list" )->asType(); + ASSERT_TRUE( bar != nullptr ); + ASSERT_TRUE( list != nullptr ); + + EXPECT_EQ( bar->getRichTextPtr()->getLines().size(), (size_t)1 ); + EXPECT_FALSE( bar->getLineWrap() ); + EXPECT_FALSE( list->getLineWrap() ); + + Engine::destroySingleton(); +} + UTEST( UIRichText, anchorMargins ) { auto win = Engine::instance()->createWindow( WindowSettings( 800, 600, "Anchor Margins Test", WindowStyle::Default, @@ -2299,6 +2419,51 @@ UTEST( UIBackground, imageAtlasPositioningPixelDensity2 ) { EE::Graphics::PixelDensity::setPixelDensity( 1.0f ); } +UTEST( UIBackground, cssFileRelativeSpriteUrlAndNegativePosition ) { + init_ui_test(); + UISceneNode* sceneNode = SceneManager::instance()->getUISceneNode(); + sceneNode->setURI( "file://" + Sys::getProcessPath() + "assets/html/" ); + + sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( R"html( + +
+ + )html" ) ); + sceneNode->combineStyleSheet( + R"css( + .sprite { + width: 15px; + height: 14px; + background-image: url(sprite-reddit.13AvZYXRW_4.png); + background-position: -42px -1678px; + background-repeat: no-repeat; + } + )css", + true, String::hash( "reddit-sprite-test" ), + URI( "file://" + Sys::getProcessPath() + + "assets/html/reddit_old_thread_files/reddit-sprite-test.css" ) ); + sceneNode->updateDirtyLayouts(); + + auto* sprite = sceneNode->find( "sprite" ); + ASSERT_TRUE( sprite != nullptr ); + auto* background = sprite->getBackground(); + ASSERT_TRUE( background != nullptr ); + auto* layer = background->getLayer( 0 ); + ASSERT_TRUE( layer != nullptr ); + ASSERT_TRUE( layer->getDrawable() != nullptr ); + + EXPECT_EQ( layer->getDrawable()->getPixelsSize().getWidth(), 140 ); + EXPECT_EQ( layer->getDrawable()->getPixelsSize().getHeight(), 1751 ); + EXPECT_STDSTREQ( layer->getPositionX(), "-42px" ); + EXPECT_STDSTREQ( layer->getPositionY(), "-1678px" ); + EXPECT_NEAR( layer->getOffset().x, -42.f, 0.1f ); + EXPECT_NEAR( layer->getOffset().y, -1678.f, 0.1f ); + EXPECT_EQ( layer->getRepeatX(), UINodeDrawable::RepeatX::NoRepeat ); + EXPECT_EQ( layer->getRepeatY(), UINodeDrawable::RepeatY::NoRepeat ); + + Engine::destroySingleton(); +} + UTEST( UIBackground, InlineBlockImageSpans ) { auto win = Engine::instance()->createWindow( WindowSettings( 1024, 653, "inline-block image spans", WindowStyle::Default,