Fix mouse wheel bubbling and plugin event handling

- Queue native wheel events until hover state has been rebuilt, restoring
  scroll propagation from children that cannot scroll to their scrollable
  ancestors.

  - Add MouseWheelEvent and UICodeEditorPlugin::onMouseWheel() so plugins can
  consume precise wheel input before the editor handles it. Migrate
  autocomplete and AI Assistant wheel handlers to the new API while
  preserving the existing virtual event dispatch semantics.

  - Add regressions for wheel bubbling, plugin interception, generic wheel
  listeners, and override-based callback suppression.
This commit is contained in:
Martín Lucas Golini
2026-09-14 17:58:06 -03:00
parent 6bccec171e
commit 51afa1870a
14 changed files with 265 additions and 22 deletions
+4
View File
@@ -13,6 +13,7 @@ namespace EE { namespace Scene {
class Node;
class MouseEvent;
class MouseWheelEvent;
class KeyEvent;
class DropEvent;
class TextEvent;
@@ -135,6 +136,7 @@ class EE_API Event {
OnShowFindReplace,
OnHideFindReplace,
OnTooltipCreated,
MouseWheel,
NoEvent = eeINDEX_NOT_FOUND
};
@@ -150,6 +152,8 @@ class EE_API Event {
const MouseEvent* asMouseEvent() const;
const MouseWheelEvent* asMouseWheelEvent() const;
const KeyEvent* asKeyEvent() const;
const DropEvent* asDropEvent() const;
+6
View File
@@ -6,6 +6,7 @@
#include <eepp/scene/nodefocusreason.hpp>
#include <eepp/system/time.hpp>
#include <eepp/window/keycodes.hpp>
#include <vector>
using namespace EE::System;
using namespace EE::Math;
@@ -147,6 +148,11 @@ class EE_API EventDispatcher {
Time mElapsed;
Uint32 mCurFocusId{ 0 };
std::map<Uint32, FocusCallback> mFocusCbs;
struct PendingMouseWheelEvent {
Vector2f offset;
bool flipped;
};
std::vector<PendingMouseWheelEvent> mPendingMouseWheelEvents;
virtual void inputCallback( InputEvent* event );
};
+16
View File
@@ -22,6 +22,22 @@ class EE_API MouseEvent : public Event {
Uint32 mFlags;
};
class EE_API MouseWheelEvent : public Event {
public:
MouseWheelEvent( Node* node, const Vector2i& position, const Vector2f& offset, bool flipped );
const Vector2i& getPosition() const;
const Vector2f& getOffset() const;
bool isFlipped() const;
protected:
Vector2i mPosition;
Vector2f mOffset;
bool mFlipped;
};
}} // namespace EE::Scene
#endif
+4 -2
View File
@@ -2124,8 +2124,10 @@ class EE_API Node : public Transformable {
/**
* @brief Handles mouse wheel scroll events.
*
* Called when the mouse wheel is scrolled. Default implementation returns 0 so the event can
* bubble to an ancestor that implements scrolling.
* Called when the mouse wheel is scrolled. The default implementation dispatches an
* Event::MouseWheel callback and consumes the event when a listener is registered; otherwise it
* returns 0 so the event can bubble to an ancestor. Overrides decide whether to invoke the base
* implementation, consistently with the other input event handlers.
*
* @param offset Scroll offset vector.
* @param flipped Whether the scroll direction is flipped (e.g., on Mac).
+4
View File
@@ -87,6 +87,10 @@ class UICodeEditorPlugin {
virtual bool onMouseUp( UICodeEditor*, const Vector2i&, const Uint32& ) { return false; }
virtual bool onMouseWheel( UICodeEditor*, const Vector2i&, const Vector2f&, bool ) {
return false;
}
virtual bool onMouseClick( UICodeEditor*, const Vector2i&, const Uint32& ) { return false; }
virtual bool onMouseDoubleClick( UICodeEditor*, const Vector2i&, const Uint32& ) {
+4
View File
@@ -25,6 +25,10 @@ const MouseEvent* Event::asMouseEvent() const {
return static_cast<const MouseEvent*>( this );
}
const MouseWheelEvent* Event::asMouseWheelEvent() const {
return static_cast<const MouseWheelEvent*>( this );
}
const KeyEvent* Event::asKeyEvent() const {
return static_cast<const KeyEvent*>( this );
}
+10 -2
View File
@@ -79,8 +79,9 @@ void EventDispatcher::inputCallback( InputEvent* event ) {
event->textediting.length );
break;
case InputEvent::MouseWheel:
sendMouseWheel( { event->wheel.x, event->wheel.y },
event->wheel.direction == InputEvent::WheelEvent::Flipped );
mPendingMouseWheelEvents.push_back(
{ { event->wheel.x, event->wheel.y },
event->wheel.direction == InputEvent::WheelEvent::Flipped } );
break;
case InputEvent::SysWM:
case InputEvent::VideoResize:
@@ -124,6 +125,13 @@ void EventDispatcher::update( const Time& time ) {
}
}
if ( !mPendingMouseWheelEvents.empty() ) {
std::vector<PendingMouseWheelEvent> pendingMouseWheelEvents;
pendingMouseWheelEvents.swap( mPendingMouseWheelEvents );
for ( const auto& event : pendingMouseWheelEvents )
sendMouseWheel( event.offset, event.flipped );
}
if ( mDisableMousePress || mJustDisabledMousePress ) {
mJustDisabledMousePress = false;
return;
+16
View File
@@ -17,4 +17,20 @@ const Uint32& MouseEvent::getFlags() const {
return mFlags;
}
MouseWheelEvent::MouseWheelEvent( Node* node, const Vector2i& position, const Vector2f& offset,
bool flipped ) :
Event( node, Event::MouseWheel ), mPosition( position ), mOffset( offset ), mFlipped( flipped ) {}
const Vector2i& MouseWheelEvent::getPosition() const {
return mPosition;
}
const Vector2f& MouseWheelEvent::getOffset() const {
return mOffset;
}
bool MouseWheelEvent::isFlipped() const {
return mFlipped;
}
}} // namespace EE::Scene
+12 -2
View File
@@ -387,8 +387,18 @@ Uint32 Node::onMouseLeave( const Vector2i& Pos, const Uint32& Flags ) {
return 1;
}
Uint32 Node::onMouseWheel( const Vector2f&, bool ) {
return 0;
Uint32 Node::onMouseWheel( const Vector2f& offset, bool flipped ) {
if ( !hasEventsOfType( Event::MouseWheel ) )
return 0;
const Vector2i position =
getEventDispatcher() ? getEventDispatcher()->getMousePos() : Vector2i::Zero;
MouseWheelEvent event( this, position, offset, flipped );
sendEvent( &event );
// A registered listener consumes the wheel event. Overrides can intentionally avoid calling
// this implementation to handle the wheel without dispatching the generic event callback.
return 1;
}
Uint32 Node::onCalculateDrag( const Vector2f&, const Uint32& ) {
+7 -1
View File
@@ -1921,7 +1921,13 @@ Uint32 UICodeEditor::onMouseUp( const Vector2i& position, const Uint32& flags )
return UIWidget::onMouseUp( position, flags );
}
Uint32 UICodeEditor::onMouseWheel( const Vector2f& offset, bool ) {
Uint32 UICodeEditor::onMouseWheel( const Vector2f& offset, bool flipped ) {
const Vector2i position = getEventDispatcher() ? getEventDispatcher()->getMousePos()
: Vector2i::Zero;
for ( auto& plugin : mPlugins )
if ( plugin->onMouseWheel( this, position, offset, flipped ) )
return 1;
Input* input = getInput();
if ( input->isKeyModPressed() )
return 1;
+167
View File
@@ -1,8 +1,14 @@
#include "utest.h"
#include <eepp/system/filesystem.hpp>
#include <eepp/scene/scenemanager.hpp>
#include <eepp/ui/uiapplication.hpp>
#include <eepp/ui/uicodeeditor.hpp>
#include <eepp/ui/uiscenenode.hpp>
#include <eepp/ui/uiscrollbar.hpp>
#include <eepp/ui/uiscrollview.hpp>
#include <eepp/window/engine.hpp>
#include <eepp/window/input.hpp>
#include <eepp/window/inputevent.hpp>
using namespace EE;
using namespace EE::System;
@@ -15,6 +21,39 @@ class ScrollingTestEditor : public UICodeEditor {
using UICodeEditor::onMouseWheel;
};
class WheelInterceptPlugin : public UICodeEditorPlugin {
public:
std::string getId() override { return "WheelInterceptPlugin"; }
std::string getTitle() override { return getId(); }
std::string getDescription() override { return getId(); }
bool isReady() const override { return true; }
void onRegister( UICodeEditor* ) override {}
void onUnregister( UICodeEditor* ) override {}
bool onMouseWheel( UICodeEditor*, const Vector2i& position, const Vector2f& offset,
bool flipped ) override {
++calls;
lastPosition = position;
lastOffset = offset;
lastFlipped = flipped;
return true;
}
int calls{ 0 };
Vector2i lastPosition;
Vector2f lastOffset;
bool lastFlipped{ false };
};
class WheelOverrideWidget : public UIWidget {
public:
Uint32 onMouseWheel( const Vector2f&, bool ) override {
++calls;
return 1;
}
int calls{ 0 };
};
UTEST( UIScrolling, SceneSmoothScrollingDefaultsCanBeInheritedSnapshottedAndAppliedNow ) {
UIApplication app(
WindowSettings{ 320, 240, "eepp - scrolling test" },
@@ -86,3 +125,131 @@ UTEST( UIScrolling, CodeEditorKeepsLegacyWheelStepButSoftensFractionalMomentumTa
eeDelete( editor );
}
UTEST( UIScrolling, WheelEventRebuildsHoverStateAndBubblesWhenEditorCannotScroll ) {
UIApplication app(
WindowSettings{ 320, 240, "eepp - wheel bubbling test" },
UIApplication::Settings( Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1 ) );
auto* scene = app.getUI();
auto* scrollView = UIScrollView::New();
scrollView->setPixelsSize( 280, 160 );
scrollView->setParent( scene->getRoot() );
auto* content = UIWidget::New();
content->setPixelsSize( 260, 600 );
content->setParent( scrollView );
auto* editor = UICodeEditor::New();
editor->setPixelsSize( 240, 80 );
editor->setParent( content );
scene->flushDirtyStyleAndLayout();
ASSERT_TRUE( scrollView->getVerticalScrollBar()->isEnabled() );
EXPECT_NEAR( scrollView->getVerticalScrollBar()->getValue(), 0.f, 0.0001f );
app.getWindow()->getInput()->setMousePos(
editor->convertToWorldSpace( { 20.f, 20.f } ).asInt() );
InputEvent event{};
event.Type = InputEvent::MouseWheel;
event.WinID = app.getWindow()->getWindowID();
event.wheel.x = 0.f;
event.wheel.y = -1.f;
event.wheel.direction = InputEvent::WheelEvent::Normal;
ASSERT_TRUE( app.getWindow()->getInput()->pushEvent( event ) );
Engine::instance()->updateInput();
SceneManager::instance()->update();
EXPECT_GT( scrollView->getVerticalScrollBar()->getValue(), 0.f );
}
UTEST( UIScrolling, CodeEditorPluginCanConsumePreciseWheelBeforeEditorScrolls ) {
UIApplication app(
WindowSettings{ 320, 240, "eepp - wheel plugin test" },
UIApplication::Settings( Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1 ) );
auto* editor = eeNew( ScrollingTestEditor, () );
editor->setPixelsSize( 160, 80 );
editor->setParent( app.getUI()->getRoot() );
editor->getDocument().textInput( String( 100, '\n' ) );
app.getUI()->flushDirtyStyleAndLayout();
editor->setScrollY( 100.f );
app.getWindow()->getInput()->setMousePos( { 25, 35 } );
SceneManager::instance()->update();
WheelInterceptPlugin plugin;
editor->registerPlugin( &plugin );
EXPECT_EQ( 1u, editor->onMouseWheel( { 0.f, -0.25f }, true ) );
EXPECT_EQ( plugin.calls, 1 );
EXPECT_EQ( plugin.lastPosition.x, 25 );
EXPECT_EQ( plugin.lastPosition.y, 35 );
EXPECT_NEAR( plugin.lastOffset.x, 0.f, 0.0001f );
EXPECT_NEAR( plugin.lastOffset.y, -0.25f, 0.0001f );
EXPECT_TRUE( plugin.lastFlipped );
EXPECT_NEAR( editor->getScroll().y, 100.f, 0.0001f );
editor->unregisterPlugin( &plugin );
eeDelete( editor );
}
UTEST( UIScrolling, MouseWheelListenerConsumesBeforeScrollableAncestor ) {
UIApplication app(
WindowSettings{ 320, 240, "eepp - wheel listener test" },
UIApplication::Settings( Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1 ) );
auto* scene = app.getUI();
auto* scrollView = UIScrollView::New();
scrollView->setPixelsSize( 280, 160 );
scrollView->setParent( scene->getRoot() );
auto* content = UIWidget::New();
content->setPixelsSize( 260, 600 );
content->setParent( scrollView );
auto* child = UIWidget::New();
child->setPixelsSize( 240, 80 );
child->setParent( content );
scene->flushDirtyStyleAndLayout();
int wheelEvents = 0;
Vector2f receivedOffset;
child->on( Event::MouseWheel, [&]( const Event* event ) {
++wheelEvents;
receivedOffset = event->asMouseWheelEvent()->getOffset();
} );
app.getWindow()->getInput()->setMousePos( child->convertToWorldSpace( { 20.f, 20.f } ).asInt() );
InputEvent event{};
event.Type = InputEvent::MouseWheel;
event.WinID = app.getWindow()->getWindowID();
event.wheel.x = 0.f;
event.wheel.y = -1.25f;
event.wheel.direction = InputEvent::WheelEvent::Normal;
ASSERT_TRUE( app.getWindow()->getInput()->pushEvent( event ) );
Engine::instance()->updateInput();
SceneManager::instance()->update();
EXPECT_EQ( wheelEvents, 1 );
EXPECT_NEAR( receivedOffset.y, -1.25f, 0.0001f );
EXPECT_NEAR( scrollView->getVerticalScrollBar()->getValue(), 0.f, 0.0001f );
}
UTEST( UIScrolling, MouseWheelOverrideCanSuppressListenerDispatch ) {
UIApplication app(
WindowSettings{ 320, 240, "eepp - wheel override test" },
UIApplication::Settings( Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1 ) );
auto* widget = eeNew( WheelOverrideWidget, () );
widget->setPixelsSize( 160, 80 );
widget->setParent( app.getUI()->getRoot() );
app.getUI()->flushDirtyStyleAndLayout();
int wheelEvents = 0;
widget->on( Event::MouseWheel, [&]( const Event* ) { ++wheelEvents; } );
app.getWindow()->getInput()->setMousePos(
widget->convertToWorldSpace( { 20.f, 20.f } ).asInt() );
InputEvent event{};
event.Type = InputEvent::MouseWheel;
event.WinID = app.getWindow()->getWindowID();
event.wheel.y = -1.f;
event.wheel.direction = InputEvent::WheelEvent::Normal;
ASSERT_TRUE( app.getWindow()->getInput()->pushEvent( event ) );
Engine::instance()->updateInput();
SceneManager::instance()->update();
EXPECT_EQ( widget->calls, 1 );
EXPECT_EQ( wheelEvents, 0 );
}
+10 -10
View File
@@ -643,9 +643,9 @@ LLMChatUI::LLMChatUI( PluginManager* manager ) :
}
} );
mModelBtn->onClick( [this]( auto ) { execute( "ai-select-model" ); } );
mModelBtn->on( Event::MouseUp, [this]( const Event* event ) {
const auto mouseEvent = event->asMouseEvent();
if ( !( mouseEvent->getFlags() & ( EE_BUTTON_WUMASK | EE_BUTTON_WDMASK ) ) )
mModelBtn->on( Event::MouseWheel, [this]( const Event* event ) {
const auto wheelEvent = event->asMouseWheelEvent();
if ( wheelEvent->getOffset().y == 0.f )
return;
if ( nullptr == mLocateModelTable->getModel() ) {
@@ -656,9 +656,9 @@ LLMChatUI::LLMChatUI( PluginManager* manager ) :
selectModel( mCurModel );
}
if ( mouseEvent->getFlags() & EE_BUTTON_WUMASK ) {
if ( wheelEvent->getOffset().y > 0.f ) {
mLocateModelTable->moveSelection( -1 );
} else if ( mouseEvent->getFlags() & EE_BUTTON_WDMASK ) {
} else {
mLocateModelTable->moveSelection( 1 );
}
@@ -674,9 +674,9 @@ LLMChatUI::LLMChatUI( PluginManager* manager ) :
mAgentBtn->onClick( [this]( auto ) { execute( "ai-select-agent" ); } );
mAgentConfigBtn = findByClass<UIPushButton>( "agent_config_ui" );
mAgentConfigBtn->onClick( [this]( auto ) { showAgentConfigWindow(); } );
mAgentBtn->on( Event::MouseUp, [this]( const Event* event ) {
const auto mouseEvent = event->asMouseEvent();
if ( !( mouseEvent->getFlags() & ( EE_BUTTON_WUMASK | EE_BUTTON_WDMASK ) ) )
mAgentBtn->on( Event::MouseWheel, [this]( const Event* event ) {
const auto wheelEvent = event->asMouseWheelEvent();
if ( wheelEvent->getOffset().y == 0.f )
return;
if ( nullptr == mLocateAgentTable->getModel() ) {
@@ -687,9 +687,9 @@ LLMChatUI::LLMChatUI( PluginManager* manager ) :
selectModel( mCurModel );
}
if ( mouseEvent->getFlags() & EE_BUTTON_WUMASK ) {
if ( wheelEvent->getOffset().y > 0.f ) {
mLocateAgentTable->moveSelection( -1 );
} else if ( mouseEvent->getFlags() & EE_BUTTON_WDMASK ) {
} else {
mLocateAgentTable->moveSelection( 1 );
}
@@ -2436,18 +2436,18 @@ bool AutoCompletePlugin::onMouseDown( UICodeEditor* editor, const Vector2i& posi
return false;
}
bool AutoCompletePlugin::onMouseUp( UICodeEditor* editor, const Vector2i& position,
const Uint32& flags ) {
bool AutoCompletePlugin::onMouseWheel( UICodeEditor* editor, const Vector2i& position,
const Vector2f& offset, bool ) {
if ( mSuggestions.empty() || !mSuggestionsEditor || mSuggestionsEditor != editor )
return false;
Vector2f localPos( editor->convertToNodeSpace( position.asFloat() ) );
if ( mBoxRect.contains( localPos ) ) {
if ( flags & EE_BUTTON_WUMASK ) {
if ( offset.y > 0.f ) {
mSuggestionsStartIndex = eemax( 0, mSuggestionsStartIndex - mSuggestionsMaxVisible );
editor->invalidateDraw();
return true;
} else if ( flags & EE_BUTTON_WDMASK ) {
} else if ( offset.y < 0.f ) {
mSuggestionsStartIndex =
eemax( 0, eemin( (int)mSuggestions.size() - mSuggestionsMaxVisible,
mSuggestionsStartIndex + mSuggestionsMaxVisible ) );
@@ -117,7 +117,7 @@ class AutoCompletePlugin : public Plugin {
bool onMouseDown( UICodeEditor*, const Vector2i&, const Uint32& ) override;
bool onMouseUp( UICodeEditor*, const Vector2i&, const Uint32& ) override;
bool onMouseWheel( UICodeEditor*, const Vector2i&, const Vector2f&, bool ) override;
bool onMouseDoubleClick( UICodeEditor*, const Vector2i&, const Uint32& ) override;