diff --git a/include/eepp/scene/event.hpp b/include/eepp/scene/event.hpp index 74768f063..5107673ad 100644 --- a/include/eepp/scene/event.hpp +++ b/include/eepp/scene/event.hpp @@ -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; diff --git a/include/eepp/scene/eventdispatcher.hpp b/include/eepp/scene/eventdispatcher.hpp index 2f40aa127..28e8291a2 100644 --- a/include/eepp/scene/eventdispatcher.hpp +++ b/include/eepp/scene/eventdispatcher.hpp @@ -6,6 +6,7 @@ #include #include #include +#include using namespace EE::System; using namespace EE::Math; @@ -147,6 +148,11 @@ class EE_API EventDispatcher { Time mElapsed; Uint32 mCurFocusId{ 0 }; std::map mFocusCbs; + struct PendingMouseWheelEvent { + Vector2f offset; + bool flipped; + }; + std::vector mPendingMouseWheelEvents; virtual void inputCallback( InputEvent* event ); }; diff --git a/include/eepp/scene/mouseevent.hpp b/include/eepp/scene/mouseevent.hpp index 9e0d64f44..2938fe320 100644 --- a/include/eepp/scene/mouseevent.hpp +++ b/include/eepp/scene/mouseevent.hpp @@ -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 diff --git a/include/eepp/scene/node.hpp b/include/eepp/scene/node.hpp index e64554e43..795807b4f 100644 --- a/include/eepp/scene/node.hpp +++ b/include/eepp/scene/node.hpp @@ -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). diff --git a/include/eepp/ui/uicodeeditor.hpp b/include/eepp/ui/uicodeeditor.hpp index 01ad23032..9cbb93ae4 100644 --- a/include/eepp/ui/uicodeeditor.hpp +++ b/include/eepp/ui/uicodeeditor.hpp @@ -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& ) { diff --git a/src/eepp/scene/event.cpp b/src/eepp/scene/event.cpp index c031124fd..7d1155c5b 100644 --- a/src/eepp/scene/event.cpp +++ b/src/eepp/scene/event.cpp @@ -25,6 +25,10 @@ const MouseEvent* Event::asMouseEvent() const { return static_cast( this ); } +const MouseWheelEvent* Event::asMouseWheelEvent() const { + return static_cast( this ); +} + const KeyEvent* Event::asKeyEvent() const { return static_cast( this ); } diff --git a/src/eepp/scene/eventdispatcher.cpp b/src/eepp/scene/eventdispatcher.cpp index 1193148d4..19cc6405d 100644 --- a/src/eepp/scene/eventdispatcher.cpp +++ b/src/eepp/scene/eventdispatcher.cpp @@ -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 pendingMouseWheelEvents; + pendingMouseWheelEvents.swap( mPendingMouseWheelEvents ); + for ( const auto& event : pendingMouseWheelEvents ) + sendMouseWheel( event.offset, event.flipped ); + } + if ( mDisableMousePress || mJustDisabledMousePress ) { mJustDisabledMousePress = false; return; diff --git a/src/eepp/scene/mouseevent.cpp b/src/eepp/scene/mouseevent.cpp index c8a54195f..29519dd11 100644 --- a/src/eepp/scene/mouseevent.cpp +++ b/src/eepp/scene/mouseevent.cpp @@ -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 diff --git a/src/eepp/scene/node.cpp b/src/eepp/scene/node.cpp index 683044eab..187108ffd 100644 --- a/src/eepp/scene/node.cpp +++ b/src/eepp/scene/node.cpp @@ -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& ) { diff --git a/src/eepp/ui/uicodeeditor.cpp b/src/eepp/ui/uicodeeditor.cpp index 076a4333d..84db768c1 100644 --- a/src/eepp/ui/uicodeeditor.cpp +++ b/src/eepp/ui/uicodeeditor.cpp @@ -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; diff --git a/src/tests/unit_tests/uiscrolling_tests.cpp b/src/tests/unit_tests/uiscrolling_tests.cpp index 4459b410f..fb4df383a 100644 --- a/src/tests/unit_tests/uiscrolling_tests.cpp +++ b/src/tests/unit_tests/uiscrolling_tests.cpp @@ -1,8 +1,14 @@ #include "utest.h" #include +#include #include #include #include +#include +#include +#include +#include +#include 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 ); +} diff --git a/src/tools/ecode/plugins/aiassistant/chatui.cpp b/src/tools/ecode/plugins/aiassistant/chatui.cpp index 8948e8bd8..a032d1304 100644 --- a/src/tools/ecode/plugins/aiassistant/chatui.cpp +++ b/src/tools/ecode/plugins/aiassistant/chatui.cpp @@ -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( "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 ); } diff --git a/src/tools/ecode/plugins/autocomplete/autocompleteplugin.cpp b/src/tools/ecode/plugins/autocomplete/autocompleteplugin.cpp index 0e3a02f4b..c59c72488 100644 --- a/src/tools/ecode/plugins/autocomplete/autocompleteplugin.cpp +++ b/src/tools/ecode/plugins/autocomplete/autocompleteplugin.cpp @@ -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 ) ); diff --git a/src/tools/ecode/plugins/autocomplete/autocompleteplugin.hpp b/src/tools/ecode/plugins/autocomplete/autocompleteplugin.hpp index b0abaf69e..4ef86fc8d 100644 --- a/src/tools/ecode/plugins/autocomplete/autocompleteplugin.hpp +++ b/src/tools/ecode/plugins/autocomplete/autocompleteplugin.hpp @@ -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;