diff --git a/.agent/plans/uiwebview_document_scene_layout_refactor.md b/.agent/plans/uiwebview_document_scene_layout_refactor.md index d920f3d3e..2b1961c6a 100644 --- a/.agent/plans/uiwebview_document_scene_layout_refactor.md +++ b/.agent/plans/uiwebview_document_scene_layout_refactor.md @@ -164,22 +164,21 @@ application UISceneNode `UIScrollView::mContainer` size updates. - Scroll extent measurement resizes `mDocumentLayout` and `mDocumentScene`, while root/html/body layout remains viewport-sized. +- Hit testing now uses a root-scoped traversal extent instead of a + `UISceneNode::overFind()` compatibility override. `UIRoot` keeps its layout/self-hit + bounds viewport-sized, but embedded document scenes can ask it to traverse child + hit testing through the measured document extent. +- Basic author `@font-face` isolation is implemented and covered by UIWebView tests. - Tests cover the new topology, viewport-vs-extent behavior, scrolling, two-scene style isolation, navigation supersession, and a resize metric regression that guards - against no-op queued viewport churn rebuilding RichText. + against no-op queued viewport churn rebuilding RichText. They also cover document + root hit testing below the layout viewport. ### Pending / Follow-Up -- **Cleaner hit-test bounds model.** The current implementation overrides - `UISceneNode::overFind()` for scenes with an explicit layout viewport. This is a - narrow compatibility hook: when the viewport-sized `mRoot` rejects a point, the scene - searches root children directly so visible scrolled document descendants can still be - hit. The cleaner long-term shape is to split layout bounds from hit-test traversal - bounds, likely on `UIRoot` or `Node`, so root can remain the layout viewport while - hit testing can intentionally traverse visible overflow without a `UISceneNode` - special case. -- **Author `@font-face` isolation and cleanup** still needs the full scene-local alias - registry and navigation cleanup described in Phase 5. +- **Author `@font-face` cleanup audit** should verify navigation/destruction cleanup + for scene-local aliases and loaded font resources. The basic scene-local isolation + path is implemented and tested. - **Subresource lifetime coverage** should be completed for every async path described in Phase 6, including deferred CSS, fonts, images, redirects, cookies, and destruction. - **Example and documentation integration** should be completed after the code shape @@ -477,6 +476,12 @@ two-web-view isolation tests pass: projects/scripts/xvfb-run-eepp bin/unit_tests/eepp-unit_tests-debug ``` +Current verification status: + +- Focused `UISceneNode.*` and `UIWebView.*` suites pass through + `projects/scripts/xvfb-run-eepp`. +- The full native Linux unit-test suite has been run and passes. + --- ## Completion Criteria diff --git a/.agent/plans/uiwebview_document_scene_plan.md b/.agent/plans/uiwebview_document_scene_plan.md index b459bcfee..ea1ddcaf9 100644 --- a/.agent/plans/uiwebview_document_scene_plan.md +++ b/.agent/plans/uiwebview_document_scene_plan.md @@ -1,7 +1,9 @@ # UIWebView Document Scene Isolation Plan -> Status: PROPOSED - investigated against the current `UIWebView`, `UISceneNode`, -> `UIScrollView`, HTML loading, and nested-scene behavior. +> Status: IMPLEMENTED WITH FOLLOW-UPS - the owned document scene, real scroll-target +> layout widget, viewport/extent split, root-scoped hit-test traversal, and focused +> UIWebView coverage are implemented. Remaining work is cleanup/audit coverage for +> async subresources, examples/docs, and fixed/sticky acceptance tests. ## Goal @@ -613,6 +615,12 @@ projects/scripts/xvfb-run-eepp bin/unit_tests/eepp-unit_tests-debug Run it after the focused isolation, scrolling, positioned-layout, media-query, and nested-scene tests pass. +Current verification status: + +- Focused `UISceneNode.*` and `UIWebView.*` suites pass through + `projects/scripts/xvfb-run-eepp`. +- The full native Linux unit-test suite has been run and passes. + ## Phase Backup Stashes Each implementation phase must end with a persistent Git stash backup after that phase's focused diff --git a/include/eepp/ui/uiroot.hpp b/include/eepp/ui/uiroot.hpp index 11b9a439b..d5c4c40f4 100644 --- a/include/eepp/ui/uiroot.hpp +++ b/include/eepp/ui/uiroot.hpp @@ -16,10 +16,24 @@ class EE_API UIRoot : public UIWidget { virtual std::vector getPropertiesImplemented() const; + Node* overFind( const Vector2f& point ); + + void setChildHitTestTraversalPixelsSize( const Sizef& size ); + + void clearChildHitTestTraversalPixelsSize(); + + bool hasChildHitTestTraversalPixelsSize() const; + + const Sizef& getChildHitTestTraversalPixelsSize() const; + protected: UIRoot(); + bool childHitTestTraversalContains( const Vector2f& point ); + Color mDroppableHoveringColor{ Color::Transparent }; + Sizef mChildHitTestTraversalPixelsSize; + bool mHasChildHitTestTraversalPixelsSize{ false }; }; }} // namespace EE::UI diff --git a/include/eepp/ui/uiscenenode.hpp b/include/eepp/ui/uiscenenode.hpp index 64c198f29..7ab4a499b 100644 --- a/include/eepp/ui/uiscenenode.hpp +++ b/include/eepp/ui/uiscenenode.hpp @@ -28,9 +28,9 @@ class UIIconThemeManager; class UIEventDispatcher; class UIWidget; class UIWindow; -class UIWidget; class UILayout; class UIIcon; +class UIRoot; struct NavigationRequest { URI uri; @@ -715,8 +715,6 @@ class EE_API UISceneNode : public SceneNode { */ void nodeToWorldTranslation( Vector2f& Pos ) const; - Node* overFind( const Vector2f& point ); - /** * @brief Reloads the UI styles. * @@ -837,7 +835,7 @@ class EE_API UISceneNode : public SceneNode { friend class EE::UI::UIWindow; friend class EE::UI::UIWidget; - UIWidget* mRoot{ nullptr }; + UIRoot* mRoot{ nullptr }; Sizef mDpSize; Uint32 mFlags; Translator mTranslator; @@ -1113,6 +1111,8 @@ class EE_API UISceneNode : public SceneNode { URI getURIFromURL( const URI& url ) const; void updateStyleSheet( bool forceReloadStyle = true ); + + void updateRootHitTestTraversalBounds(); }; }} // namespace EE::UI diff --git a/src/eepp/ui/uiroot.cpp b/src/eepp/ui/uiroot.cpp index 2cd0fedd4..572fa3918 100644 --- a/src/eepp/ui/uiroot.cpp +++ b/src/eepp/ui/uiroot.cpp @@ -8,6 +8,84 @@ UIRoot* UIRoot::New() { UIRoot::UIRoot() : UIWidget( ":root" ) {} +Node* UIRoot::overFind( const Vector2f& point ) { + if ( !mHasChildHitTestTraversalPixelsSize ) + return UIWidget::overFind( point ); + + Node* pOver = nullptr; + + if ( ( mNodeFlags & NODE_FLAG_OVER_FIND_ALLOWED ) && mEnabled && mVisible ) { + updateWorldPolygon(); + + const bool selfHit = mWorldBounds.contains( point ) && mPoly.pointInside( point ); + if ( selfHit || childHitTestTraversalContains( point ) ) { + Node* child = getLastChild(); + while ( nullptr != child ) { + Node* childOver = child->overFind( point ); + if ( nullptr != childOver ) { + pOver = childOver; + break; + } + child = child->getPrevNode(); + } + + if ( nullptr != pOver || selfHit ) { + writeNodeFlag( NODE_FLAG_MOUSEOVER_ME_OR_CHILD, 1 ); + mSceneNode->addMouseOverNode( this ); + } + + if ( nullptr == pOver && selfHit ) + pOver = this; + } + } + + return pOver; +} + +void UIRoot::setChildHitTestTraversalPixelsSize( const Sizef& size ) { + if ( mHasChildHitTestTraversalPixelsSize && mChildHitTestTraversalPixelsSize == size ) + return; + + mChildHitTestTraversalPixelsSize = size; + mHasChildHitTestTraversalPixelsSize = true; +} + +void UIRoot::clearChildHitTestTraversalPixelsSize() { + if ( !mHasChildHitTestTraversalPixelsSize ) + return; + + mChildHitTestTraversalPixelsSize = Sizef::Zero; + mHasChildHitTestTraversalPixelsSize = false; +} + +bool UIRoot::hasChildHitTestTraversalPixelsSize() const { + return mHasChildHitTestTraversalPixelsSize; +} + +const Sizef& UIRoot::getChildHitTestTraversalPixelsSize() const { + return mChildHitTestTraversalPixelsSize; +} + +bool UIRoot::childHitTestTraversalContains( const Vector2f& point ) { + updateWorldPolygon(); + + Polygon2f traversalPoly = Polygon2f( Rectf( + mScreenPos.x, mScreenPos.y, mScreenPos.x + mChildHitTestTraversalPixelsSize.getWidth(), + mScreenPos.y + mChildHitTestTraversalPixelsSize.getHeight() ) ); + traversalPoly.rotate( getRotation(), getRotationCenter() ); + traversalPoly.scale( getScale(), getScaleCenter() ); + + Node* parent = getParent(); + while ( parent ) { + traversalPoly.rotate( parent->getRotation(), parent->getRotationCenter() ); + traversalPoly.scale( parent->getScale(), parent->getScaleCenter() ); + parent = parent->getParent(); + } + + const Rectf traversalWorldBounds = traversalPoly.getBounds(); + return traversalWorldBounds.contains( point ) && traversalPoly.pointInside( point ); +} + std::string UIRoot::getPropertyString( const PropertyDefinition* propertyDef, const Uint32& propertyIndex ) const { if ( NULL == propertyDef ) diff --git a/src/eepp/ui/uiscenenode.cpp b/src/eepp/ui/uiscenenode.cpp index 5c684f202..44fc300dd 100644 --- a/src/eepp/ui/uiscenenode.cpp +++ b/src/eepp/ui/uiscenenode.cpp @@ -179,54 +179,6 @@ void UISceneNode::nodeToWorldTranslation( Vector2f& Pos ) const { } } -Node* UISceneNode::overFind( const Vector2f& point ) { - Node* pOver = NULL; - - if ( ( mNodeFlags & NODE_FLAG_OVER_FIND_ALLOWED ) && mEnabled && mVisible ) { - updateWorldPolygon(); - - if ( mWorldBounds.contains( point ) && mPoly.pointInside( point ) ) { - writeNodeFlag( NODE_FLAG_MOUSEOVER_ME_OR_CHILD, 1 ); - mSceneNode->addMouseOverNode( this ); - - Node* child = getLastChild(); - - while ( NULL != child ) { - Node* childOver = child->overFind( point ); - - if ( NULL == childOver && child == mRoot && mHasLayoutViewportPixelsSize ) { - mRoot->updateWorldPolygon(); - mRoot->writeNodeFlag( NODE_FLAG_MOUSEOVER_ME_OR_CHILD, 1 ); - mSceneNode->addMouseOverNode( mRoot ); - - Node* rootChild = mRoot->getLastChild(); - while ( NULL != rootChild ) { - childOver = rootChild->overFind( point ); - if ( NULL != childOver ) - break; - rootChild = rootChild->getPrevNode(); - } - - if ( NULL == childOver ) - childOver = mRoot; - } - - if ( NULL != childOver ) { - pOver = childOver; - break; - } - - child = child->getPrevNode(); - } - - if ( NULL == pOver ) - pOver = this; - } - } - - return pOver; -} - void UISceneNode::onParentChange() { SceneNode::onParentChange(); @@ -797,6 +749,7 @@ void UISceneNode::setInternalSize( const Sizef& size ) { mSize = PixelDensity::dpToPx( size ); updateCenter(); onSizeChange(); + updateRootHitTestTraversalBounds(); sendCommonEvent( Event::OnSizeChange ); invalidateDraw(); } @@ -849,6 +802,7 @@ void UISceneNode::setViewportPixelsSize( const Sizef& size ) { mViewportPixelsSize = size; mHasViewportPixelsSize = true; mRoot->setPixelsSize( getRootPixelsSize() ); + updateRootHitTestTraversalBounds(); onViewportPixelsSizeChange(); } @@ -858,6 +812,7 @@ void UISceneNode::clearViewportPixelsSize() { mHasViewportPixelsSize = false; mRoot->setPixelsSize( getRootPixelsSize() ); + updateRootHitTestTraversalBounds(); onViewportPixelsSizeChange(); } @@ -878,6 +833,7 @@ void UISceneNode::setLayoutViewportPixelsSize( const Sizef& size ) { mLayoutViewportPixelsSize = size; mHasLayoutViewportPixelsSize = true; mRoot->setPixelsSize( getRootPixelsSize() ); + updateRootHitTestTraversalBounds(); sendMsg( this, NodeMessage::WindowResize ); } @@ -887,6 +843,7 @@ void UISceneNode::clearLayoutViewportPixelsSize() { mHasLayoutViewportPixelsSize = false; mRoot->setPixelsSize( getRootPixelsSize() ); + updateRootHitTestTraversalBounds(); sendMsg( this, NodeMessage::WindowResize ); } @@ -1695,11 +1652,23 @@ void UISceneNode::setInternalPixelsSize( const Sizef& size ) { mNodeFlags |= NODE_FLAG_POLYGON_DIRTY; updateCenter(); onSizeChange(); + updateRootHitTestTraversalBounds(); sendCommonEvent( Event::OnSizeChange ); invalidateDraw(); } } +void UISceneNode::updateRootHitTestTraversalBounds() { + if ( !mRoot ) + return; + + if ( mHasLayoutViewportPixelsSize ) { + mRoot->setChildHitTestTraversalPixelsSize( mSize ); + } else { + mRoot->clearChildHitTestTraversalPixelsSize(); + } +} + Uint32 UISceneNode::onKeyDown( const KeyEvent& event ) { std::string cmd = mKeyBindings.getCommandFromKeyBind( { event.getKeyCode(), event.getMod() } ); if ( !cmd.empty() ) { diff --git a/src/tests/unit_tests/uiscenenode_tests.cpp b/src/tests/unit_tests/uiscenenode_tests.cpp index d8627e6e3..042fb9bfd 100644 --- a/src/tests/unit_tests/uiscenenode_tests.cpp +++ b/src/tests/unit_tests/uiscenenode_tests.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -150,6 +151,44 @@ UTEST( UISceneNode, EmbeddedSceneCanKeepContentDrivenExtent ) { Engine::destroySingleton(); } +UTEST( UISceneNode, LayoutViewportRootTraversesDocumentExtentForHitTesting ) { + Engine::instance()->createWindow( WindowSettings( 1024, 768, "Embedded Scene Hit Test Bounds", + WindowStyle::Default, WindowBackend::Default, + 32, {}, 1, false, true ), + ContextSettings( false, 0, 0, GLv_default, true, false ) ); + + UISceneNode* hostScene = init_test_scene_node(); + UIWidget* host = UIWidget::New(); + host->setPixelsSize( 800, 600 ); + host->setParent( hostScene->getRoot() ); + + UISceneNode* embeddedScene = UISceneNode::New(); + embeddedScene->setFollowParentSize( false ); + embeddedScene->setParent( host ); + embeddedScene->setPixelsSize( 800, 1000 ); + embeddedScene->setLayoutViewportPixelsSize( Sizef( 800, 300 ) ); + + UIWidget* target = UIWidget::New(); + target->setId( "below-layout-viewport" ); + target->setPixelsSize( 100, 50 ); + target->setPosition( 10, 900 ); + target->setParent( embeddedScene->getRoot() ); + + UIRoot* embeddedRoot = embeddedScene->getRoot()->asType(); + ASSERT_TRUE( embeddedRoot != nullptr ); + ASSERT_TRUE( embeddedRoot->hasChildHitTestTraversalPixelsSize() ); + EXPECT_NEAR( embeddedScene->getRoot()->getPixelsSize().getHeight(), 300.f, 0.5f ); + EXPECT_NEAR( embeddedRoot->getChildHitTestTraversalPixelsSize().getHeight(), 1000.f, 0.5f ); + + Node* hit = embeddedScene->overFind( Vector2f( 20, 920 ) ); + EXPECT_EQ( hit, target ); + + embeddedScene->clearLayoutViewportPixelsSize(); + EXPECT_FALSE( embeddedRoot->hasChildHitTestTraversalPixelsSize() ); + + Engine::destroySingleton(); +} + UTEST( UISceneNode, NestedSceneOwnsDescendantSceneAndStyles ) { Engine::instance()->createWindow( WindowSettings( 1024, 768, "Nested Scene Isolation Test", WindowStyle::Default, WindowBackend::Default, diff --git a/src/tests/unit_tests/uiwebview_tests.cpp b/src/tests/unit_tests/uiwebview_tests.cpp index 4746be7e9..9c181522b 100644 --- a/src/tests/unit_tests/uiwebview_tests.cpp +++ b/src/tests/unit_tests/uiwebview_tests.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -92,6 +93,83 @@ UTEST( UIWebView, OwnedDocumentSceneScrollTarget ) { Engine::destroySingleton(); } +UTEST( UIWebView, DocumentRootHitTestingTraversesScrollableExtent ) { + auto win = Engine::instance()->createWindow( + WindowSettings( 640, 480, "UIWebView Document Hit Test Bounds", WindowStyle::Default, + WindowBackend::Default, 32, {}, 1, false, true ), + ContextSettings( false, 0, 0, GLv_default, true, false ) ); + FileSystem::changeWorkingDirectory( Sys::getProcessPath() ); + + FontTrueType* font = FontTrueType::New( "NotoSans-Regular" ); + font->loadFromFile( "../assets/fonts/NotoSans-Regular.ttf" ); + ASSERT_TRUE( font != nullptr && font->loaded() ); + FontFamily::loadFromRegular( font ); + + UISceneNode* sceneNode = UISceneNode::New(); + SceneManager::instance()->add( sceneNode ); + sceneNode->getUIThemeManager()->setDefaultFont( font ); + + UIWebView* webView = UIWebView::New(); + webView->setParent( sceneNode->getRoot() ); + webView->setPixelsSize( 400, 300 ); + webView->setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::Fixed ); + webView->getVerticalScrollBar()->setPixelsSize( 15, 300 ); + webView->getHorizontalScrollBar()->setPixelsSize( 400, 15 ); + + const std::string path = Sys::getTempPath() + "eepp_uiwebview_hit_test_extent.html"; + FileSystem::fileWrite( path, R"html( + + + + +
+
+ + +)html" ); + webView->loadURI( URI( "file://" + path ) ); + + UISceneNode* documentScene = webView->getDocumentSceneNode(); + ASSERT_TRUE( documentScene != nullptr ); + + auto pump = [&]() { + for ( int i = 0; i < 30; i++ ) { + win->getInput()->update(); + SceneManager::instance()->update( Seconds( 1.f / 60.f ) ); + } + }; + pump(); + + Node* targetNode = documentScene->getRoot()->find( "target" ); + ASSERT_TRUE( targetNode != nullptr && targetNode->isWidget() ); + UIWidget* target = targetNode->asType(); + ASSERT_TRUE( webView->getVerticalScrollBar()->isVisible() ); + UIRoot* documentRoot = documentScene->getRoot()->asType(); + ASSERT_TRUE( documentRoot != nullptr ); + ASSERT_TRUE( documentRoot->hasChildHitTestTraversalPixelsSize() ); + EXPECT_NEAR( documentScene->getRoot()->getPixelsSize().getHeight(), + documentScene->getViewportPixelsSize().getHeight(), 0.5f ); + EXPECT_NEAR( documentRoot->getChildHitTestTraversalPixelsSize().getHeight(), + documentScene->getPixelsSize().getHeight(), 0.5f ); + + webView->getVerticalScrollBar()->setValue( 1.f ); + pump(); + + const Rectf targetRect = target->getScreenRect(); + const Rectf rootRect = documentScene->getRoot()->getScreenRect(); + const Rectf containerRect = webView->getContainer()->getScreenRect(); + const Vector2f hitPoint( targetRect.Left + targetRect.getWidth() * 0.5f, + targetRect.Top + targetRect.getHeight() * 0.5f ); + ASSERT_TRUE( containerRect.contains( hitPoint ) ); + ASSERT_FALSE( rootRect.contains( hitPoint ) ); + + Node* hitNode = sceneNode->overFind( hitPoint ); + ASSERT_TRUE( hitNode != nullptr ); + EXPECT_TRUE( hitNode == target || target->isParentOf( hitNode ) ); + + Engine::destroySingleton(); +} + UTEST( UIWebView, VerticalScrollbarViewportDoesNotCreateHorizontalScroll ) { auto win = Engine::instance()->createWindow( WindowSettings( 640, 480, "UIWebView Scrollbar Viewport Test", WindowStyle::Default,