Simplify mouse-over ancestry tracking

- Derive isMouseOverMeOrChildren() from the event dispatcher's committed
  mouse-over node instead of maintaining a transient flag on every node.

  - Remove the mouse-over tracking set, per-frame flag clearing, and hover
  bookkeeping side effects from hit testing. This makes hover ancestry
  available between scene updates while reducing SceneNode size and
  eliminating hash-set operations during overFind().

  - Add coverage for persistent ancestry, sibling transitions, hover state
  synchronization, and side-effect-free hit testing.
This commit is contained in:
Martín Lucas Golini
2026-09-14 18:29:51 -03:00
parent 51afa1870a
commit 71360cb896
11 changed files with 72 additions and 62 deletions

View File

@@ -62,7 +62,6 @@ enum NodeFlags {
NODE_FLAG_MOUSEOVER = ( 1 << 7 ),
NODE_FLAG_HAS_FOCUS = ( 1 << 8 ),
NODE_FLAG_SELECTED = ( 1 << 9 ),
NODE_FLAG_MOUSEOVER_ME_OR_CHILD = ( 1 << 10 ),
NODE_FLAG_DRAGGING = ( 1 << 11 ),
NODE_FLAG_SKIN_OWNER = ( 1 << 12 ),
NODE_FLAG_TOUCH_DRAGGING = ( 1 << 13 ),
@@ -633,12 +632,22 @@ class EE_API Node : public Transformable {
inline bool isMouseOver() const { return 0 != ( mNodeFlags & NODE_FLAG_MOUSEOVER ); }
/**
* @brief Checks if the mouse is over this node or any of its children.
* @brief Checks if this node is the current mouse-over target or one of its ancestors.
*
* This reflects the event dispatcher's last completed hit test and remains valid between scene
* updates.
*
* @return True if the mouse is over this node or any descendant, false otherwise.
*/
inline bool isMouseOverMeOrChildren() const {
return 0 != ( mNodeFlags & NODE_FLAG_MOUSEOVER_ME_OR_CHILD );
EventDispatcher* dispatcher = getEventDispatcher();
Node* overNode = dispatcher ? dispatcher->getMouseOverNode() : nullptr;
while ( overNode ) {
if ( overNode == this )
return true;
overNode = overNode->mParentNode;
}
return false;
}
/**

View File

@@ -351,23 +351,6 @@ class EE_API SceneNode : public Node {
*/
bool isSubscribedForScheduledUpdate( Node* node );
/**
* @brief Adds a node to the mouse-over tracking list.
*
* This is used internally to track which nodes are currently under
* the mouse cursor.
*
* @param node Pointer to the node to track.
*/
void addMouseOverNode( Node* node );
/**
* @brief Removes a node from the mouse-over tracking list.
*
* @param node Pointer to the node to stop tracking.
*/
void removeMouseOverNode( Node* node );
/**
* @brief Gets whether all children are updated each frame.
*
@@ -442,7 +425,6 @@ class EE_API SceneNode : public Node {
Time mElapsed;
UnorderedSet<Node*> mScheduledUpdate;
UnorderedSet<Node*> mScheduledUpdateRemove;
UnorderedSet<Node*> mMouseOverNodes;
Float mDPI;
virtual void onSizeChange();

View File

@@ -72,9 +72,6 @@ template <class TContainer> Node* UIItemContainer<TContainer>::overFind( const V
updateWorldPolygon();
if ( mWorldBounds.contains( Point ) && mPoly.pointInside( Point ) ) {
writeNodeFlag( NODE_FLAG_MOUSEOVER_ME_OR_CHILD, 1 );
mSceneNode->addMouseOverNode( this );
for ( Uint32 i = tParent->mVisibleFirst; i <= tParent->mVisibleLast; i++ ) {
if ( NULL != tParent->mItems[i] ) {
Node* ChildOver = tParent->mItems[i]->overFind( Point );

View File

@@ -41,9 +41,6 @@ Node::~Node() {
if ( mNodeFlags & NODE_FLAG_SCHEDULED_UPDATE )
mSceneNode->unsubscribeScheduledUpdate( this );
if ( isMouseOverMeOrChildren() )
mSceneNode->removeMouseOverNode( this );
}
childDeleteAll();
@@ -291,8 +288,6 @@ void Node::update( const Time& time ) {
childLoop->update( time );
childLoop = childLoop->mNext;
}
writeNodeFlag( NODE_FLAG_MOUSEOVER_ME_OR_CHILD, 0 );
}
void Node::sendMouseEvent( const Uint32& event, const Vector2i& pos, const Uint32& flags ) {
@@ -973,9 +968,6 @@ Node* Node::overFind( const Vector2f& point ) {
updateWorldPolygon();
if ( mWorldBounds.contains( point ) && mPoly.pointInside( point ) ) {
writeNodeFlag( NODE_FLAG_MOUSEOVER_ME_OR_CHILD, 1 );
mSceneNode->addMouseOverNode( this );
Node* child = mChildLast;
while ( NULL != child ) {

View File

@@ -160,14 +160,8 @@ void SceneNode::update( const Time& time ) {
node->scheduledUpdate( time );
}
if ( mUpdateAllChildren ) {
if ( mUpdateAllChildren )
Node::update( time );
} else {
for ( auto& nodeOver : mMouseOverNodes )
nodeOver->writeNodeFlag( NODE_FLAG_MOUSEOVER_ME_OR_CHILD, 0 );
}
mMouseOverNodes.clear();
}
void SceneNode::onSizeChange() {
@@ -488,14 +482,6 @@ bool SceneNode::isSubscribedForScheduledUpdate( Node* node ) {
return mScheduledUpdate.count( node ) > 0;
}
void SceneNode::addMouseOverNode( Node* node ) {
mMouseOverNodes.insert( node );
}
void SceneNode::removeMouseOverNode( Node* node ) {
mMouseOverNodes.erase( node );
}
bool SceneNode::getUpdateAllChildren() const {
return mUpdateAllChildren;
}

View File

@@ -858,9 +858,6 @@ Node* UIHTMLWidget::overFind( const Vector2f& point ) {
updateWorldPolygon();
if ( mWorldBounds.contains( point ) && mPoly.pointInside( point ) ) {
writeNodeFlag( NODE_FLAG_MOUSEOVER_ME_OR_CHILD, 1 );
mSceneNode->addMouseOverNode( this );
const auto& sortedChildren = getPaintOrder();
// Drawing and hit-testing share one sequence; reverse it so the last painted node wins.

View File

@@ -29,11 +29,6 @@ Node* UIRoot::overFind( const Vector2f& point ) {
child = child->getPrevNode();
}
if ( nullptr != pOver || selfHit ) {
writeNodeFlag( NODE_FLAG_MOUSEOVER_ME_OR_CHILD, 1 );
mSceneNode->addMouseOverNode( this );
}
if ( nullptr == pOver && selfHit )
pOver = this;
}

View File

@@ -94,8 +94,6 @@ Node* UITableView::overFind( const Vector2f& point ) {
ConditionalLock l( getModel() != nullptr, getModel() ? &getModel()->resourceMutex() : nullptr );
updateWorldPolygon();
if ( mWorldBounds.contains( point ) && mPoly.pointInside( point ) ) {
writeNodeFlag( NODE_FLAG_MOUSEOVER_ME_OR_CHILD, 1 );
mSceneNode->addMouseOverNode( this );
if ( mHScroll->isVisible() && ( pOver = mHScroll->overFind( point ) ) )
return pOver;
if ( mVScroll->isVisible() && ( pOver = mVScroll->overFind( point ) ) )

View File

@@ -725,9 +725,6 @@ Node* UITextSpan::overFind( const Vector2f& point ) {
}
if ( hit ) {
writeNodeFlag( NODE_FLAG_MOUSEOVER_ME_OR_CHILD, 1 );
mSceneNode->addMouseOverNode( this );
Node* child = mChildLast;
while ( NULL != child ) {

View File

@@ -447,8 +447,6 @@ Node* UITreeView::overFind( const Vector2f& point ) {
if ( mEnabled && mVisible ) {
updateWorldPolygon();
if ( mWorldBounds.contains( point ) && mPoly.pointInside( point ) ) {
writeNodeFlag( NODE_FLAG_MOUSEOVER_ME_OR_CHILD, 1 );
mSceneNode->addMouseOverNode( this );
if ( mHScroll->isVisible() && ( pOver = mHScroll->overFind( point ) ) )
return pOver;
if ( mVScroll->isVisible() && ( pOver = mVScroll->overFind( point ) ) )

View File

@@ -35,6 +35,65 @@ UTEST( UISceneNode, CssPointerCursorUsesHandCursor ) {
EXPECT_STREQ( Cursor::toName( Cursor::Arrow ), "arrow" );
}
UTEST( UISceneNode, MouseOverAncestryUsesCommittedOverNode ) {
UIApplication app(
WindowSettings{ 320, 240, "Mouse Over Ancestry" },
UIApplication::Settings( Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1.f ) );
auto* scene = app.getUI();
auto* parent = UIWidget::New();
parent->setPixelsSize( 200, 100 );
parent->setParent( scene->getRoot() );
auto* firstChild = UIWidget::New();
firstChild->setPixelsSize( 80, 80 );
firstChild->setParent( parent );
auto* secondChild = UIWidget::New();
secondChild->setPixelsPosition( 100, 0 );
secondChild->setPixelsSize( 80, 80 );
secondChild->setParent( parent );
scene->flushDirtyStyleAndLayout();
Input* input = app.getWindow()->getInput();
input->setMousePos( firstChild->convertToWorldSpace( { 20.f, 20.f } ).asInt() );
SceneManager::instance()->update();
EXPECT_EQ( scene->getEventDispatcher()->getMouseOverNode(), firstChild );
EXPECT_TRUE( firstChild->isMouseOverMeOrChildren() );
EXPECT_TRUE( firstChild->isMouseOver() );
EXPECT_TRUE( parent->isMouseOverMeOrChildren() );
EXPECT_TRUE( parent->isMouseOver() );
EXPECT_FALSE( secondChild->isMouseOverMeOrChildren() );
EXPECT_FALSE( secondChild->isMouseOver() );
const Vector2i secondChildPosition = secondChild->convertToWorldSpace( { 20.f, 20.f } ).asInt();
EXPECT_EQ( scene->overFind( secondChildPosition.asFloat() ), secondChild );
EXPECT_EQ( scene->getEventDispatcher()->getMouseOverNode(), firstChild );
EXPECT_TRUE( firstChild->isMouseOverMeOrChildren() );
EXPECT_TRUE( firstChild->isMouseOver() );
EXPECT_FALSE( secondChild->isMouseOverMeOrChildren() );
EXPECT_FALSE( secondChild->isMouseOver() );
input->setMousePos( secondChildPosition );
SceneManager::instance()->update();
EXPECT_EQ( scene->getEventDispatcher()->getMouseOverNode(), secondChild );
EXPECT_FALSE( firstChild->isMouseOverMeOrChildren() );
EXPECT_FALSE( firstChild->isMouseOver() );
EXPECT_TRUE( secondChild->isMouseOverMeOrChildren() );
EXPECT_TRUE( secondChild->isMouseOver() );
EXPECT_TRUE( parent->isMouseOverMeOrChildren() );
EXPECT_TRUE( parent->isMouseOver() );
input->setMousePos( { 280, 180 } );
SceneManager::instance()->update();
EXPECT_FALSE( firstChild->isMouseOverMeOrChildren() );
EXPECT_FALSE( firstChild->isMouseOver() );
EXPECT_FALSE( secondChild->isMouseOverMeOrChildren() );
EXPECT_FALSE( secondChild->isMouseOver() );
EXPECT_FALSE( parent->isMouseOverMeOrChildren() );
EXPECT_FALSE( parent->isMouseOver() );
}
UTEST( UISceneNode, ScopedContextBindsNodesAndRestoresNestedScene ) {
auto* engine = Engine::instance();
auto* window = engine->getCurrentWindow();