Skip to content

fix!: Add HoverCallbacks.onHoverCancel for press-while-hovering#3912

Open
Barba2k2 wants to merge 4 commits intoflame-engine:mainfrom
Barba2k2:fix/hover-callbacks-during-press
Open

fix!: Add HoverCallbacks.onHoverCancel for press-while-hovering#3912
Barba2k2 wants to merge 4 commits intoflame-engine:mainfrom
Barba2k2:fix/hover-callbacks-during-press

Conversation

@Barba2k2
Copy link
Copy Markdown

@Barba2k2 Barba2k2 commented May 2, 2026

Description

Flutter's MouseRegion only emits onHover events while no buttons are pressed. The moment a user presses a button mid-hover, MouseRegion.onHover stops firing and the hovered HoverCallbacks component never learns the hover ended — see #2741. The component stays "hovered" forever from its own perspective, even though no further hover events will ever arrive until release.

This PR introduces an explicit cancel signal for that transition rather than synthesizing fake hover events from PointerMove (which an earlier revision of this PR did). Per review feedback from @spydon and @luanpotter, the synthesized-hover approach affected components that mix HoverCallbacks with DragCallbacks — drags would have started leaking into the hover dispatcher — which is a larger behavior change than introducing a new no-op callback.

What changed

  • New virtual HoverCallbacks.onHoverCancel(). Default is a no-op, so existing components don't need to change. Override it to clear hover-driven state (e.g. a highlight) when a press interrupts the hover.
  • PointerMoveDispatcher gains an internal _handlePointerPress hook that walks its already-tracked _records and fires cancelHover() on any hovered HoverCallbacks. The hover state ends and stays ended until a fresh button-free hover re-enters the area.
  • Game.mousePressDetector is added, mirroring the existing mouseDetector setter pattern. The dispatcher wires it on mount, clears it on remove.
  • applyMouseDetectors forwards Listener.onPointerDown to mousePressDetector. DragCallbacks is not affected — drags continue to flow through MultiDragDispatcher exactly as before; the press hook only consults components that mix in HoverCallbacks.

Behavior summary

Action Before this PR After this PR
Hover into a component onHoverEnter fires onHoverEnter fires
Press a button while hovering (silent — bug #2741) onHoverCancel fires
Drag the pressed pointer outside (silent) (silent — already cancelled)
Release the button and re-hover (silent — still "hovered") onHoverEnter fires again
Hover out without pressing onHoverExit fires onHoverExit fires (unchanged)

Tests

hover_callbacks_test.dart adds a testWidgets regression that pumps a GameWidget, hovers into a component, presses, drags around while held (verifying no spurious enter/exit/cancel beyond the initial cancel), then releases and re-hovers (verifying onHoverEnter fires again). The existing pure-dispatcher test for plain hover behavior is untouched.

Checklist

  • I have followed the Contributor Guide when preparing my PR.
  • I have updated/added tests for ALL new/updated/fixed functionality.
  • I have updated/added relevant documentation in docs and added dartdoc comments with ///.
  • [-] I have updated/added relevant examples in examples or docs.

Breaking Change?

  • Yes, this PR is a breaking change.
  • No, this PR is not a breaking change.

Migration instructions

Adding the new onHoverCancel virtual to HoverCallbacks is source-compatible (default is a no-op), but the runtime behavior of pressing a button while hovering changes:

  • If you previously assumed isHovered stayed true while a button was held, that assumption no longer holds — isHovered becomes false the moment a press starts.
  • If you currently rely on onHoverExit firing on press (it didn't reliably before this fix in any case), override onHoverCancel instead — it's the explicit hook for that transition and won't be confused with a "mouse left the area" exit.

No migration is required for components that don't observe these specific edge cases.

Related Issues

Fixes #2741

Barba2k2 added 2 commits May 2, 2026 12:23
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 flame-engine#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 flame-engine#2741
Format the touched file to match `melos run format-check`.
Copy link
Copy Markdown
Member

@spydon spydon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey, thanks for your contribution.
Please follow the PR template, there are tags in there used for the part that should go into the git history.

Copy link
Copy Markdown
Member

@spydon spydon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Drag out while still pressed — expect onHoverExit to fire (this fails on main without the patch).

It's no longer a hover event once a button is pressed.
We should call onHoverCancel as soon as a button is pressed though.

Edit: apparently it is still counted as a hover in some languages, but this would be a breaking change for us so we would have to think about how to handle this...

@Barba2k2
Copy link
Copy Markdown
Author

Barba2k2 commented May 3, 2026

@spydon reformatted the description to follow the PR template. On the onHoverCancel point — agreed that's the semantically correct long-term fix, but since it's a breaking change to the existing HoverCallbacks contract I left it for a follow-up and kept this PR focused on stopping the events from being dropped. Happy to fold the onHoverCancel semantics into a separate PR once the team decides on the migration story.

@Barba2k2 Barba2k2 requested a review from spydon May 3, 2026 14:01
@luanpotter
Copy link
Copy Markdown
Member

No matter the route we go here it is technically a "breaking change" - we are changing the behaviour of hovers regardless. So I think we should just pick what we think is the ideal end state and just bite the bullet.

From what I am gathering the options are:

  1. "continue" the hover as buttons get pressed; hover eventually ends when the mouse leaves, regardless of button press [this PR]
  2. "cancel" the hover as soon as a button is pressed, firing the (currently) missing cancelled event [@spydon's suggestion]

I don't have a strong preference for behaviour here, it seems that 1 is contradicting what Flutter would do but it is arguably more intuitive? wondering if there are any reasons they decided to not consider hovers after a press. I do like 2 as a pretty reasonable option that is more in-line with how Flutter handles things.

@spydon
Copy link
Copy Markdown
Member

spydon commented May 5, 2026

If we go with 1 it will also be affecting drags right?
Feels like less of a breaking change to go with 2.

@spydon
Copy link
Copy Markdown
Member

spydon commented May 5, 2026

but since it's a breaking change to the existing HoverCallbacks contract I left it for a follow-up and kept this PR focused on stopping the events from being dropped.

@Barba2k2 I'd argue that this is a bigger breaking change, since it now will affect people that mix HoverCallbacks and DragCallbacks, they don't expect the hover callbacks to be triggering for drags. Introducing the onHoverCancel should not affect as many people.

@Barba2k2
Copy link
Copy Markdown
Author

Barba2k2 commented May 6, 2026

@spydon @luanpotter — converged, going with option 2. Concrete plan before I push so we agree on shape:

Public API (HoverCallbacks)

/// Called when a hover is interrupted because a pointer button was pressed
/// while the component was hovered. After a cancel, the component is no
/// longer in the hovered state; `onHoverEnter` will fire again only when a
/// fresh button-free hover re-enters the area.
void onHoverCancel() {}

Internal _doHoverCancel() sets _isHovered = false and calls onHoverCancel() (mirroring _doHoverEnter/_doHoverExit).

Wiring

  • PointerMoveDispatcher gains an onPointerDown(flutter.PointerDownEvent) that walks _records and, for any tagged component that is also a HoverCallbacks with isHovered == true, calls _doHoverCancel() and removes the record. Then drops the synthesize-hover-from-move bridge entirely — MouseRegion.onHover stays the only feed.
  • applyMouseDetectors adds Listener.onPointerDown that forwards to the dispatcher (gated on mouseDetector != null, same pattern as today).
  • DragCallbacks is unaffected: drags continue to flow through MultiDragDispatcher as today. The cancel only fires for components that mix in HoverCallbacks.

Tests

Replace the synthesized-hover regression test with two:

  1. Hover in → press → expect onHoverCancel (not onHoverExit); release → re-hover → expect onHoverEnter again.
  2. Hover in → press-drag-out-and-back-while-pressed → expect no further enter/exit/cancel beyond the initial cancel (i.e. press truly ends the hover until release).

Breaking surface

Adding a virtual no-op method to a mixin isn't a source-break, but the behavior on press changes (today: nothing fires; this PR: onHoverCancel fires). Marking the PR breaking and adding a migration note: "If you previously relied on onHoverExit firing on press (it didn't in most cases), override onHoverCancel instead."

If shape looks right I'll push — otherwise tell me what to tweak (naming, where to hook the press event, whether to also cancel on onPointerCancel).

…ne#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) <noreply@anthropic.com>
@Barba2k2 Barba2k2 changed the title fix: Fire HoverCallbacks while the mouse button is held feat!: Add HoverCallbacks.onHoverCancel for press-while-hovering May 6, 2026
@Barba2k2
Copy link
Copy Markdown
Author

Barba2k2 commented May 6, 2026

Pivot pushed in 145641f — implementation matches the plan I laid out above. Title bumped to feat!: and the description has the migration note. Tests pass locally (flutter test packages/flame/test/events/: 92/92). Ready for another look @spydon @luanpotter.

Copy link
Copy Markdown
Member

@spydon spydon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lgtm, thanks for your contribution

@spydon spydon changed the title feat!: Add HoverCallbacks.onHoverCancel for press-while-hovering fix!: Add HoverCallbacks.onHoverCancel for press-while-hovering May 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Incorrect behavior of HoverCallbacks when the mouse is pressed

3 participants