From 6cc2df1502c426a14342620154db990c56a81b07 Mon Sep 17 00:00:00 2001 From: Lorenzo DZ Date: Sat, 2 May 2026 12:23:02 -0300 Subject: [PATCH 1/3] fix: Fire HoverCallbacks while the mouse button is held MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flutter's MouseRegion only emits onHover events when no buttons are pressed. During a press the same physical motion surfaces as a PointerMoveEvent on the surrounding Listener and never reaches the hover dispatcher, so HoverCallbacks components stop receiving onHoverEnter / onHoverExit the moment the user clicks down — the behaviour reported in issue #2741. Forward Listener.onPointerMove to the same game.mouseDetector by synthesising a PointerHoverEvent from the move event's pointer data. The synthesized: true flag preserves provenance for any consumer that inspects it. Adds a widget regression test that hovers in, presses, drags out, and drags back in to verify enter/exit fire on each transition. Fixes #2741 --- .../game_widget/gesture_detector_builder.dart | 54 ++++++++++++++++--- .../hover_callbacks_test.dart | 40 ++++++++++++++ 2 files changed, 86 insertions(+), 8 deletions(-) diff --git a/packages/flame/lib/src/game/game_widget/gesture_detector_builder.dart b/packages/flame/lib/src/game/game_widget/gesture_detector_builder.dart index 3e309d17490..191bdd1ccd1 100644 --- a/packages/flame/lib/src/game/game_widget/gesture_detector_builder.dart +++ b/packages/flame/lib/src/game/game_widget/gesture_detector_builder.dart @@ -1,7 +1,9 @@ import 'package:flame/events.dart'; import 'package:flame/input.dart'; import 'package:flame/src/game/game.dart'; -import 'package:flutter/gestures.dart'; +import 'package:flutter/gestures.dart' hide PointerMoveEvent; +import 'package:flutter/gestures.dart' as flutter + show PointerMoveEvent; import 'package:flutter/widgets.dart'; class GestureDetectorBuilder { @@ -185,13 +187,15 @@ Widget applyMouseDetectors(Game game, Widget child) { final mouseDetector = game.mouseDetector; final scrollDetector = game.scrollDetector; return Listener( - child: MouseRegion( - child: child, - onHover: (PointerHoverEvent e) { - mouseMoveFn?.call(PointerHoverInfo.fromDetails(game, e)); - mouseDetector?.call(e); - }, - ), + // Flutter's MouseRegion only emits onHover when no buttons are pressed — + // during a press the same gesture surfaces as a PointerMoveEvent on the + // surrounding Listener. Forward those moves to the same mouse detector + // so HoverCallbacks (and similar) keep firing onHoverEnter / onHoverExit + // while the mouse is held down. See issue #2741. + onPointerMove: mouseDetector == null + ? null + : (flutter.PointerMoveEvent e) => + mouseDetector(_hoverFromPointerMove(e)), onPointerSignal: (event) { if (event is PointerScrollEvent) { if (game is ScrollDetector) { @@ -200,5 +204,39 @@ Widget applyMouseDetectors(Game game, Widget child) { scrollDetector?.call(event); } }, + child: MouseRegion( + onHover: (PointerHoverEvent e) { + mouseMoveFn?.call(PointerHoverInfo.fromDetails(game, e)); + mouseDetector?.call(e); + }, + child: child, + ), + ); +} + +PointerHoverEvent _hoverFromPointerMove(flutter.PointerMoveEvent e) { + return PointerHoverEvent( + viewId: e.viewId, + timeStamp: e.timeStamp, + kind: e.kind, + pointer: e.pointer, + device: e.device, + position: e.position, + delta: e.delta, + buttons: e.buttons, + obscured: e.obscured, + pressureMin: e.pressureMin, + pressureMax: e.pressureMax, + distance: e.distance, + distanceMax: e.distanceMax, + size: e.size, + radiusMajor: e.radiusMajor, + radiusMinor: e.radiusMinor, + radiusMin: e.radiusMin, + radiusMax: e.radiusMax, + orientation: e.orientation, + tilt: e.tilt, + synthesized: true, + embedderId: e.embedderId, ); } diff --git a/packages/flame/test/events/component_mixins/hover_callbacks_test.dart b/packages/flame/test/events/component_mixins/hover_callbacks_test.dart index 061b3bc77bc..eaddaf9551c 100644 --- a/packages/flame/test/events/component_mixins/hover_callbacks_test.dart +++ b/packages/flame/test/events/component_mixins/hover_callbacks_test.dart @@ -2,6 +2,7 @@ import 'package:flame/components.dart'; import 'package:flame/events.dart'; import 'package:flame/game.dart'; import 'package:flame_test/flame_test.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -53,6 +54,45 @@ void main() { _mouseEvent(game, Vector2.all(20)); component.checkHoverEventCounts(enter: 2, exit: 2); }); + + testWidgets( + 'fires enter and exit while the mouse button is held (regression #2741)', + (tester) async { + final component = _HoverCallbacksComponent( + position: Vector2.all(10), + size: Vector2.all(10), + ); + await tester.pumpWidget( + GameWidget(game: FlameGame(children: [component])), + ); + await tester.pump(); + + final pointer = TestPointer(1, PointerDeviceKind.mouse); + + // Hover into the component — no buttons pressed. + await tester.sendEventToBinding(pointer.hover(const Offset(15, 15))); + component.checkHoverEventCounts(enter: 1, exit: 0); + + // Press the primary button while hovering. + await tester.sendEventToBinding(pointer.down(const Offset(15, 15))); + + // Drag out of the component while still pressed — onHoverExit must + // fire even though no PointerHoverEvent is produced during a press. + await tester.sendEventToBinding( + pointer.move(const Offset(40, 40), buttons: kPrimaryButton), + ); + component.checkHoverEventCounts(enter: 1, exit: 1); + + // Drag back into the component while still pressed — onHoverEnter + // must fire again. + await tester.sendEventToBinding( + pointer.move(const Offset(15, 15), buttons: kPrimaryButton), + ); + component.checkHoverEventCounts(enter: 2, exit: 1); + + await tester.sendEventToBinding(pointer.up()); + }, + ); }); } From 72ff7e7d97f8f91c69d51e8b7dbf66fe238ac3e7 Mon Sep 17 00:00:00 2001 From: Lorenzo DZ Date: Sat, 2 May 2026 12:58:29 -0300 Subject: [PATCH 2/3] fix: Apply dart format to gesture_detector_builder.dart Format the touched file to match `melos run format-check`. --- .../lib/src/game/game_widget/gesture_detector_builder.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/flame/lib/src/game/game_widget/gesture_detector_builder.dart b/packages/flame/lib/src/game/game_widget/gesture_detector_builder.dart index 191bdd1ccd1..000ee53fcff 100644 --- a/packages/flame/lib/src/game/game_widget/gesture_detector_builder.dart +++ b/packages/flame/lib/src/game/game_widget/gesture_detector_builder.dart @@ -2,8 +2,7 @@ import 'package:flame/events.dart'; import 'package:flame/input.dart'; import 'package:flame/src/game/game.dart'; import 'package:flutter/gestures.dart' hide PointerMoveEvent; -import 'package:flutter/gestures.dart' as flutter - show PointerMoveEvent; +import 'package:flutter/gestures.dart' as flutter show PointerMoveEvent; import 'package:flutter/widgets.dart'; class GestureDetectorBuilder { From 145641f7bb4bf6e9ba2f9f13d7f8cf67815b5ef4 Mon Sep 17 00:00:00 2001 From: Lorenzo DZ Date: Tue, 5 May 2026 23:14:26 -0300 Subject: [PATCH 3/3] refactor: Pivot to onHoverCancel for press-while-hovering (#2741) Replace the synthesize-hover-from-PointerMove bridge with a dedicated onHoverCancel() callback on HoverCallbacks. PointerMoveDispatcher now walks its tracked records on PointerDown and fires cancelHover() on any hovered HoverCallbacks, mirroring how DragCallbacks handles cancellation without affecting the existing drag dispatcher. Per maintainer review on the PR (spydon, luanpotter): synthesizing hover events during a press affects components that mix HoverCallbacks with DragCallbacks, which is a larger behavior change than introducing a new no-op callback. Adding onHoverCancel keeps the existing hover contract intact for unaffected users and makes the press semantics explicit for those who want to react to it. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../component_mixins/hover_callbacks.dart | 20 ++++++++ .../pointer_move_dispatcher.dart | 19 ++++++++ packages/flame/lib/src/game/game.dart | 13 ++++++ .../game_widget/gesture_detector_builder.dart | 46 ++++--------------- .../hover_callbacks_test.dart | 42 ++++++++++++----- 5 files changed, 91 insertions(+), 49 deletions(-) diff --git a/packages/flame/lib/src/events/component_mixins/hover_callbacks.dart b/packages/flame/lib/src/events/component_mixins/hover_callbacks.dart index 20d4aefab8c..b57cbedff72 100644 --- a/packages/flame/lib/src/events/component_mixins/hover_callbacks.dart +++ b/packages/flame/lib/src/events/component_mixins/hover_callbacks.dart @@ -23,6 +23,16 @@ mixin HoverCallbacks on Component implements PointerMoveCallbacks { void onHoverExit() {} + /// Called when a hover is interrupted because a pointer button was pressed + /// while the component was hovered. + /// + /// Flutter does not emit `PointerHoverEvent`s while a button is held, so the + /// hover state ends as soon as the press starts. Override this to react to + /// that transition (e.g. clear a hover-driven highlight). After a cancel, + /// [onHoverEnter] will fire again only when a fresh button-free hover + /// re-enters the area. + void onHoverCancel() {} + void _doHoverEnter() { _isHovered = true; onHoverEnter(); @@ -33,6 +43,16 @@ mixin HoverCallbacks on Component implements PointerMoveCallbacks { onHoverExit(); } + /// Called by [PointerMoveDispatcher] when a pointer button is pressed while + /// this component is hovered. Not intended to be called by user code. + @internal + void cancelHover() { + if (_isHovered) { + _isHovered = false; + onHoverCancel(); + } + } + @override void onPointerMove(PointerMoveEvent event) { final position = event.localPosition; diff --git a/packages/flame/lib/src/events/flame_game_mixins/pointer_move_dispatcher.dart b/packages/flame/lib/src/events/flame_game_mixins/pointer_move_dispatcher.dart index b47ab5d36d8..1720bedf1e3 100644 --- a/packages/flame/lib/src/events/flame_game_mixins/pointer_move_dispatcher.dart +++ b/packages/flame/lib/src/events/flame_game_mixins/pointer_move_dispatcher.dart @@ -44,6 +44,23 @@ class PointerMoveDispatcher extends Dispatcher { onMouseMove(PointerMoveEvent.fromPointerHoverEvent(game, event)); } + /// Cancels the hover on every currently-hovered [HoverCallbacks] tracked by + /// this dispatcher when a pointer button is pressed. Flutter routes such + /// presses through `Listener.onPointerDown` (not `MouseRegion.onHover`), so + /// without this hook hovered components would never learn the hover ended. + /// See issue #2741. + void _handlePointerPress(flutter.PointerDownEvent _) { + final cancelled = >[]; + for (final record in _records) { + final component = record.component; + if (component is HoverCallbacks && component.isHovered) { + component.cancelHover(); + cancelled.add(record); + } + } + _records.removeAll(cancelled); + } + static void addDispatcher(Component component) { Dispatcher.addDispatcher( component, @@ -55,11 +72,13 @@ class PointerMoveDispatcher extends Dispatcher { @override void onMount() { game.mouseDetector = _handlePointerMove; + game.mousePressDetector = _handlePointerPress; } @override void onRemove() { game.mouseDetector = null; + game.mousePressDetector = null; Dispatcher.removeDispatcher(game, const MouseMoveDispatcherKey()); } } diff --git a/packages/flame/lib/src/game/game.dart b/packages/flame/lib/src/game/game.dart index 4dc45729157..67ca4827b89 100644 --- a/packages/flame/lib/src/game/game.dart +++ b/packages/flame/lib/src/game/game.dart @@ -47,6 +47,19 @@ abstract mixin class Game { refreshWidget(); } + /// Set by the PointerMoveDispatcher to receive mouse press events from the + /// game widget so it can fire `onHoverCancel` on hovered `HoverCallbacks` + /// components when the user presses a button while hovering. + void Function(PointerDownEvent event)? get mousePressDetector => + _mousePressDetector; + void Function(PointerDownEvent event)? _mousePressDetector; + set mousePressDetector( + void Function(PointerDownEvent event)? newMousePressDetector, + ) { + _mousePressDetector = newMousePressDetector; + refreshWidget(); + } + /// Set by the ScrollDispatcher to receive pointer scroll events from the /// game widget. void Function(PointerScrollEvent event)? get scrollDetector => diff --git a/packages/flame/lib/src/game/game_widget/gesture_detector_builder.dart b/packages/flame/lib/src/game/game_widget/gesture_detector_builder.dart index 000ee53fcff..4699abe1337 100644 --- a/packages/flame/lib/src/game/game_widget/gesture_detector_builder.dart +++ b/packages/flame/lib/src/game/game_widget/gesture_detector_builder.dart @@ -1,8 +1,7 @@ import 'package:flame/events.dart'; import 'package:flame/input.dart'; import 'package:flame/src/game/game.dart'; -import 'package:flutter/gestures.dart' hide PointerMoveEvent; -import 'package:flutter/gestures.dart' as flutter show PointerMoveEvent; +import 'package:flutter/gestures.dart'; import 'package:flutter/widgets.dart'; class GestureDetectorBuilder { @@ -178,23 +177,21 @@ bool hasMouseDetectors(Game game) { return game is MouseMovementDetector || game is ScrollDetector || game.mouseDetector != null || + game.mousePressDetector != null || game.scrollDetector != null; } Widget applyMouseDetectors(Game game, Widget child) { final mouseMoveFn = game is MouseMovementDetector ? game.onMouseMove : null; final mouseDetector = game.mouseDetector; + final mousePressDetector = game.mousePressDetector; final scrollDetector = game.scrollDetector; return Listener( - // Flutter's MouseRegion only emits onHover when no buttons are pressed — - // during a press the same gesture surfaces as a PointerMoveEvent on the - // surrounding Listener. Forward those moves to the same mouse detector - // so HoverCallbacks (and similar) keep firing onHoverEnter / onHoverExit - // while the mouse is held down. See issue #2741. - onPointerMove: mouseDetector == null - ? null - : (flutter.PointerMoveEvent e) => - mouseDetector(_hoverFromPointerMove(e)), + // Forward pointer-down to the dispatcher so it can fire `onHoverCancel` + // on hovered HoverCallbacks components — Flutter stops emitting + // PointerHoverEvents the moment a button is pressed, so without this hook + // the hover state would silently linger. See issue #2741. + onPointerDown: mousePressDetector, onPointerSignal: (event) { if (event is PointerScrollEvent) { if (game is ScrollDetector) { @@ -212,30 +209,3 @@ Widget applyMouseDetectors(Game game, Widget child) { ), ); } - -PointerHoverEvent _hoverFromPointerMove(flutter.PointerMoveEvent e) { - return PointerHoverEvent( - viewId: e.viewId, - timeStamp: e.timeStamp, - kind: e.kind, - pointer: e.pointer, - device: e.device, - position: e.position, - delta: e.delta, - buttons: e.buttons, - obscured: e.obscured, - pressureMin: e.pressureMin, - pressureMax: e.pressureMax, - distance: e.distance, - distanceMax: e.distanceMax, - size: e.size, - radiusMajor: e.radiusMajor, - radiusMinor: e.radiusMinor, - radiusMin: e.radiusMin, - radiusMax: e.radiusMax, - orientation: e.orientation, - tilt: e.tilt, - synthesized: true, - embedderId: e.embedderId, - ); -} diff --git a/packages/flame/test/events/component_mixins/hover_callbacks_test.dart b/packages/flame/test/events/component_mixins/hover_callbacks_test.dart index eaddaf9551c..c93c8be1dcf 100644 --- a/packages/flame/test/events/component_mixins/hover_callbacks_test.dart +++ b/packages/flame/test/events/component_mixins/hover_callbacks_test.dart @@ -56,7 +56,8 @@ void main() { }); testWidgets( - 'fires enter and exit while the mouse button is held (regression #2741)', + 'fires onHoverCancel when a button is pressed mid-hover ' + '(regression #2741)', (tester) async { final component = _HoverCallbacksComponent( position: Vector2.all(10), @@ -71,26 +72,28 @@ void main() { // Hover into the component — no buttons pressed. await tester.sendEventToBinding(pointer.hover(const Offset(15, 15))); - component.checkHoverEventCounts(enter: 1, exit: 0); + component.checkHoverEventCounts(enter: 1, exit: 0, cancel: 0); - // Press the primary button while hovering. + // Press the primary button while hovering — onHoverCancel must fire + // (not onHoverExit; the press semantically ends the hover). await tester.sendEventToBinding(pointer.down(const Offset(15, 15))); + component.checkHoverEventCounts(enter: 1, exit: 0, cancel: 1); - // Drag out of the component while still pressed — onHoverExit must - // fire even though no PointerHoverEvent is produced during a press. + // Drag around while pressed — no further hover events should fire, + // because we are no longer hovering anything until release. await tester.sendEventToBinding( pointer.move(const Offset(40, 40), buttons: kPrimaryButton), ); - component.checkHoverEventCounts(enter: 1, exit: 1); - - // Drag back into the component while still pressed — onHoverEnter - // must fire again. await tester.sendEventToBinding( pointer.move(const Offset(15, 15), buttons: kPrimaryButton), ); - component.checkHoverEventCounts(enter: 2, exit: 1); + component.checkHoverEventCounts(enter: 1, exit: 0, cancel: 1); + // Release and re-hover — onHoverEnter must fire again, since the + // hover state is now eligible to resume. await tester.sendEventToBinding(pointer.up()); + await tester.sendEventToBinding(pointer.hover(const Offset(15, 15))); + component.checkHoverEventCounts(enter: 2, exit: 0, cancel: 1); }, ); }); @@ -115,8 +118,13 @@ void _hasDispatcher(FlameGame game) { mixin _HoverInspector on HoverCallbacks { int hoverEnterEvent = 0; int hoverExitEvent = 0; + int hoverCancelEvent = 0; - void checkHoverEventCounts({required int enter, required int exit}) { + void checkHoverEventCounts({ + required int enter, + required int exit, + int? cancel, + }) { expect( hoverEnterEvent, equals(enter), @@ -127,6 +135,13 @@ mixin _HoverInspector on HoverCallbacks { equals(exit), reason: 'Mismatched hover exit event count', ); + if (cancel != null) { + expect( + hoverCancelEvent, + equals(cancel), + reason: 'Mismatched hover cancel event count', + ); + } } @override @@ -138,6 +153,11 @@ mixin _HoverInspector on HoverCallbacks { void onHoverExit() { hoverExitEvent++; } + + @override + void onHoverCancel() { + hoverCancelEvent++; + } } class _HoverCallbacksComponent extends PositionComponent