Cleaner hit testing: removed UISceneNode::overFind() override; UIRoot now has root-scoped child traversal extent for embedded document scenes.

This commit is contained in:
Martín Lucas Golini
2026-07-01 23:11:11 -03:00
parent e210736c2d
commit 5ad166b648
8 changed files with 256 additions and 65 deletions
@@ -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
+10 -2
View File
@@ -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
+14
View File
@@ -16,10 +16,24 @@ class EE_API UIRoot : public UIWidget {
virtual std::vector<PropertyId> 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
+4 -4
View File
@@ -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
+78
View File
@@ -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 )
+17 -48
View File
@@ -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() ) {
@@ -5,6 +5,7 @@
#include <eepp/scene/scenemanager.hpp>
#include <eepp/system/filesystem.hpp>
#include <eepp/system/sys.hpp>
#include <eepp/ui/uiroot.hpp>
#include <eepp/ui/uiscenenode.hpp>
#include <eepp/ui/uithememanager.hpp>
#include <eepp/ui/uiwidget.hpp>
@@ -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<UIRoot>();
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,
+78
View File
@@ -7,6 +7,7 @@
#include <eepp/system/filesystem.hpp>
#include <eepp/system/sys.hpp>
#include <eepp/ui/uilayout.hpp>
#include <eepp/ui/uiroot.hpp>
#include <eepp/ui/uiscenenode.hpp>
#include <eepp/ui/uiscrollbar.hpp>
#include <eepp/ui/uithememanager.hpp>
@@ -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(
<!DOCTYPE html>
<html>
<head><style>html, body { margin: 0; padding: 0; }</style></head>
<body>
<div style="height: 900px"></div>
<div id="target" style="width: 220px; height: 100px; background: #abcdef"></div>
</body>
</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<UIWidget>();
ASSERT_TRUE( webView->getVerticalScrollBar()->isVisible() );
UIRoot* documentRoot = documentScene->getRoot()->asType<UIRoot>();
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,