Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Control's mouse_exit() is emitted when the mouse is moved to another control inside that control #16854

Closed
Duehok opened this issue Feb 20, 2018 · 36 comments · Fixed by #56708
Closed

Comments

@Duehok
Copy link

Duehok commented Feb 20, 2018

Godot version: 3.0 stable, mono 5.4.1.7

OS/device including version:
Win 8.1

Issue description:
The mouse_exited() signal has the following description in the docs:
http://docs.godotengine.org/en/3.0/classes/class_control.html

Emitted when the mouse leaves the control’s Rect area, provided its mouse_filter lets the event reach it.

And for NOTIFICATION_MOUSE_EXIT:

Sent when the mouse pointer exits the node’s Rect area.

Well, that's not the case. The signal and notification are also emitted from control A when the cursor goes to another control B inside and children of A. The behavior is the same whether B has MouseFilter set to Stop or more surprisingly to Pass.
I am guessing that the issue comes from the fact that an object with MouseFilter set to pass only transmit click events and not all mouse events. (according to MouseFilter Pass documentation).

My use case is a small menu that should disappear when the
mouse_exited[1].zip
cursor moves outside. Ideally I would just call queue_free when mouse_exited is received, but in reality this cause the menu to disappear when the mouse moves to a button of the menu.
I tried to add a check to the mouse_exited() event: call queue_free only when mouse_exited() is sent AND the mouse is actually outside of the menu's rect. This does not work either: moving quickly from a button to outside of the menu means that the mouse is never registered as having entered the menu's control, so mouse_exited() is never called.

The first workaround that I found is to check for the cursor's position relative to the control's rect inside _Process(delta) which is very very ugly.
Another one is to add a control that pass mouse clicks on top of the menu, but that's not really clean either.

The best solution would be to have control with mouseFilter set to Pass to pass all mouse info and not only clicks.
The less good solution would be to correct the docs of mouse_exited(), NOTIFICATION_MOUSE_EXIT, mouse_entered() and NOTIFICATION_MOUSE_ENTER.

Steps to reproduce:
Create any control (parentControl)
Create another control inside it (childControl)
set childControl's mouseFilter property to "Pass"

parentControl's mouse_exited() signal fires when the cursor is moved from parentControl to childControl

Minimal reproduction project:
mouse_exited.zip

@woubuc
Copy link

woubuc commented May 23, 2019

I'm experiencing the same problem. 3.1.1 stable mono. Any updates / workarounds for this?

@Reneator
Copy link

Same here, having a way to say "If the mouse is inside the container" instead of "if the mouse is inside the container, as long as its not hovering over a child of the container" would be great.

@konsvasi
Copy link

I'm facing the same issue on v3.2.1

@Reneator
Copy link

Reneator commented Apr 11, 2020

I'm experiencing the same problem. 3.1.1 stable mono. Any updates / workarounds for this?

Sorry for the late comment, maybe you have already solved this problem or moved on, but writing this to be helpful for other encountering this problem:

A simple but brute-force method is to check if the mouse coordinates are inside the Control:

	var global_position = node.rect_global_position
	var min_x = global_position.x
	var min_y = global_position.y
	var max_x = global_position.x + node.rect_size.x
	var max_y = global_position.y + node.rect_size.y
	var mouse_is_inside = mouse.x > min_x and mouse.y > min_y and mouse.x < max_x and mouse.y < max_y

A simpler method is via has_point()

var mouse_is_inside = node.get_global_rect().has_point(node.get_global_mouse_position()):

Edit: edited source-code formatting

@ignoxx
Copy link

ignoxx commented Aug 10, 2020

I'm facing the same issue on v3.2.2 stable build, ubuntu 20.04

@Stovehead
Copy link

I'm having the same issue on 3.2.2 stable, but with Area2D.

@louiidev
Copy link

louiidev commented Aug 16, 2020

I'm having the same issue on 3.2.2 stable, but with Area2D. on MacOS Catalina ver 10.15

EDIT:
I realised the reason it was happening for me was because I had children nodes

@db0
Copy link

db0 commented Nov 12, 2020

Yes, it looks like the mouse_exited() and mouse_entered() signals are sent even when a the mouse moves from one control node, to a child node inside that node while that child is set to pass. I believe this is happening because even though the signal passes through the child, it is coming from a different source. Before, the input event it was coming from the viewport, while now it's coming from the child.

I do agree that this is counter-intuitive though.

@db0
Copy link

db0 commented Nov 22, 2020

Ugh, the workaround with checking manually if the mouse is within the children rect is really hacky and has too many edge cases. For example, If I rotate the parent, the mouse-coordinates check is of course not aware, so I would need to offset the position it thinks the children rect are by the rotation they received.

There has to be a better way...

@Arecher
Copy link

Arecher commented Dec 4, 2020

Been headbutting into this issue a few times now as well. It's very counter-intuitive and hard to track down. mouse_exited signals should not go off in my opinion, if a child is entered within the same frame (which passes the input back to the parent). Neither should parent nodes signal mouse_exited and mouse_entered when the cursor leaves the rect of one of their children (on pass), and enters the parent's rect. Having both mouse_exited and mouse_entered fire at the same time from the same parent-node can only lead to bugs.

@Demindiro
Copy link
Contributor

I encountered this issue too but I have an alternative solution that is (IMO) more convienent for my problem.

I have an Autoload that plays a sound when a button is hovered over. However due to this issue the sound gets played when the cursor leaves the Control too. To fix this I came up with this:

var audio := AudioStreamPlayer.new()


func _enter_tree() -> void:
	var e := get_tree().connect("node_added", self, "node_added")
	assert(e == OK)
	e = get_tree().connect("node_removed", self, "node_removed")
	assert(e == OK)
	audio.stream = preload("res://addons/ui/397599__nightflame__menu-fx-02.wav")
	audio.bus = "UI"
	add_child(audio)


func node_added(node: Node) -> void:
	if node is BaseButton:
		var e := node.connect("mouse_entered", self, "mouse_entered", [node])
		assert(e == OK)
		e = node.connect("mouse_exited", self, "mouse_exited", [node])
		assert(e == OK)
		# Recursing is necessary because https://github.com/godotengine/godot/issues/16854
		for n in Util.get_children_recursive(node):
			if n is Control:
				e = n.connect("mouse_entered", self, "mouse_entered", [node])
				assert(e == OK)
				e = n.connect("mouse_exited", self, "mouse_exited", [node])
				assert(e == OK)
		node.set_meta("ui_audio_mouse_entered", false)


func mouse_entered(node: BaseButton) -> void:
	assert(node != null)
	if not node.get_meta("ui_audio_mouse_entered"):
		audio.play()
	node.call_deferred("set_meta", "ui_audio_mouse_entered", true)


func mouse_exited(node: BaseButton) -> void:
	node.call_deferred("set_meta", "ui_audio_mouse_entered", false)

When the mouse enters a node or any of it children, ui_audio_mouse_entered is set to true at the end of the frame. When it exits any of the nodes, it is set to false, but because the call is deferred any nodes that are entered afterwards won't cause a sound to be played because ui_audio_mouse_entered remains true until the end of the frame (and it will be set to true immediately afterwards too).

@JonathanGrant92
Copy link

JonathanGrant92 commented Feb 26, 2021

A simpler method is via has_point()

var mouse_is_inside = node.get_global_rect().has_point(node.get_global_mouse_position()):

Edit: edited source-code formatting

@Reneator Thank you for sharing this idea!

Your solution didn't work in my implementation. One of my solutions involving TextureRect and PanelContainer (aka popup) nodes inside an hboxcontainer in my viewport corner is:

var popup = get_node("PanelContainer")

fun _on_AudioPlayerIcon_mouse_entered():
  popup.show()

fun _on_Popup_mouse_exited():
  if !popup.get_rect().has_point(get_local_mouse_position()):
    popup.hide()

It doesn't hide the panel too soon, but it does stick around if the user doesn't press a button first. Instead, additional flat (camouflaged) buttons bordering the panel edges with mouse exited signals is an effective workaround. Fortunately, if you nest the border buttons, one inside an hboxcontainer, which is inside a vboxcontainer holding the second border button, both border buttons autofit to the panel edges and can share the same identifier while other panel contents fills the panel.

func _on_AudioPlayerIcon_moused_entered():
  popup.show()

func _on_BorderButton_moused_exited():
  popup.hide()

And that works flawlessly.

@solarnoon
Copy link

On 3.2.3 stable and I'm also having this issue. The mouse moves into the area and mouse_exited is called immediately after mouse_entered, even when the mouse remains within the area. I'm working around it by manually checking the boundaries but it's super clunky and I'd really love for this function to actually work :(

@Jattmackson
Copy link

A simpler method is via has_point()

var mouse_is_inside = node.get_global_rect().has_point(node.get_global_mouse_position()):

Based on this if you make a control node scene and attach this script:

extends Control
class_name MouseIn

signal mouse_in
signal mouse_out

var mouse_in = false

func _process(delta):
	if "rect_size" in get_parent():
		if get_parent().get_global_rect().has_point(get_global_mouse_position()):
			if !mouse_in:
				emit_signal("mouse_in")
				mouse_in = true
		else:
			if mouse_in:
				emit_signal("mouse_out")
				mouse_in = false

This creates a node type called "MouseIn".

You add this node as a child to the UI element you want to check. The "mouse_in" and "mouse_out" signals behave as required. This means you don't have to rewrite this code for every element.

Just make sure when attaching signals you use "mouse_in" and "mouse_out" rather than "mouse_entered" or "mouse_exited" which still exist as it inherits from control.

Hope this helps.

@dhAlcojor
Copy link

I'm having the same problem, using Godot 3.3.2 (stable at the moment of writing) and with a TextureProgress with no children.

@Calinou Calinou changed the title mouse_exit() emitted when mouse still inside control Control's mouse_exit() is emitted when the mouse is moved to another control inside that control Jul 25, 2021
@Calinou
Copy link
Member

Calinou commented Jul 25, 2021

@dhAlcojor This sounds different from all the above reports so far, so please upload a minimal reproduction project to make this easier to troubleshoot.

@dhAlcojor
Copy link

@Calinou Nevermind, I prepared the reproduction project and it wasn't happening. I tested the code in my project again and, somehow, it's working now. I don't understand it...

@elvisish
Copy link

A simpler method is via has_point()
var mouse_is_inside = node.get_global_rect().has_point(node.get_global_mouse_position()):

Based on this if you make a control node scene and attach this script:

extends Control
class_name MouseIn

signal mouse_in
signal mouse_out

var mouse_in = false

func _process(delta):
	if "rect_size" in get_parent():
		if get_parent().get_global_rect().has_point(get_global_mouse_position()):
			if !mouse_in:
				emit_signal("mouse_in")
				mouse_in = true
		else:
			if mouse_in:
				emit_signal("mouse_out")
				mouse_in = false

This creates a node type called "MouseIn".

You add this node as a child to the UI element you want to check. The "mouse_in" and "mouse_out" signals behave as required. This means you don't have to rewrite this code for every element.

Just make sure when attaching signals you use "mouse_in" and "mouse_out" rather than "mouse_entered" or "mouse_exited" which still exist as it inherits from control.

Hope this helps.

Thanks so much for this, works perfectly. But a workaround shouldn't have to be used, should it? The intended use of mouse_entered and mouse_exit when combined with the right mouse filter settings should allow for this?

@IvanIG3
Copy link

IvanIG3 commented Sep 8, 2021

A simpler method is via has_point()
var mouse_is_inside = node.get_global_rect().has_point(node.get_global_mouse_position()):

Based on this if you make a control node scene and attach this script:

extends Control
class_name MouseIn

signal mouse_in
signal mouse_out

var mouse_in = false

func _process(delta):
	if "rect_size" in get_parent():
		if get_parent().get_global_rect().has_point(get_global_mouse_position()):
			if !mouse_in:
				emit_signal("mouse_in")
				mouse_in = true
		else:
			if mouse_in:
				emit_signal("mouse_out")
				mouse_in = false

This creates a node type called "MouseIn".

You add this node as a child to the UI element you want to check. The "mouse_in" and "mouse_out" signals behave as required. This means you don't have to rewrite this code for every element.

Just make sure when attaching signals you use "mouse_in" and "mouse_out" rather than "mouse_entered" or "mouse_exited" which still exist as it inherits from control.

Hope this helps.

Thanks for this, but be warned that this workaround will trigger the signals always, even if the control is overlapped by other controls. In some cases, this is not the expected behavior.

@Reneator
Copy link

Reneator commented Sep 9, 2021

I adapted to this restriction by making sure that the children of a Node either have mouse_filter.IGNORE or by having the parent hook up to signals of the children that have mouse_filter.PASS or STOP.

Combining this with the manual check if the mouse is inside with:
var mouse_is_inside = node.get_global_rect().has_point(node.get_global_mouse_position())

Makes it possible to cover all functionalities/fronts.

But this also might make it necessary to check for special conditions, like when i have a big menu/overlay over the entire UI, the "has_point" fires, even though you might have a different control in front. As mouse_entered and mouse_exited respects the mouse_filter and visual overlay of control nodes.

I am compensating this by having bools that get set when a control that covers the screen is shown, like my option_menu or a dialog with an npc with a switch like: "is_option_menu_open" or "is_dialogue_screen_open" which i can then manually check in the "is mouse inside" of the control/node

@IvanIG3
Copy link

IvanIG3 commented Sep 9, 2021

I'm trying to avoid the mouse_entered and mouse_exited signals when hovering a child control, and at the same time, I'm trying to not firing always the mouse_in and mouse_out from Jattmackson's code. This is my proposal, still very far from perfect, but maybe someone finds it useful.

class_name MouseInControl
extends Node

signal mouse_in
signal mouse_out

onready var _parent = get_parent()
var mouse_in = false
var _mouse_control = false

func _ready():
	_parent.connect('mouse_entered', self, '_on_mouse_entered')
	_parent.connect('mouse_exited', self, '_on_mouse_exited')

func _process(_delta):
	if not mouse_in and _mouse_control:
		mouse_in = true
		emit_signal('mouse_in')
	elif mouse_in and not _mouse_control:
		mouse_in = false
		emit_signal('mouse_out')
	if _mouse_control and not _mouse_in_control():
		_mouse_control = false

func _on_mouse_entered():
	if _mouse_in_control():
		_mouse_control = true

func _on_mouse_exited():
	if not _mouse_in_control():
		_mouse_control = false

func _mouse_in_control():
	var mouse_pos = _parent.get_global_mouse_position()
	var parent_rect = _get_global_rect(_parent)
	if parent_rect:
		return parent_rect.has_point(mouse_pos)
	else:
		return false

func _get_global_rect(node):
	if node.has_method('get_global_rect'):
		return node.get_global_rect()
	return null

@EricEzaM
Copy link
Contributor

EricEzaM commented Sep 27, 2021

It is really weird, check this out.

VXSC2SK3tm.mp4

But the behaviour is completely different for labels and text boxes:

R2VCszbmv8.mp4

Tested on v4.0.dev.custom_build [8138280] btw

Here is the code which calls mouse enter/exit notifications:

godot/scene/main/viewport.cpp

Lines 1577 to 1587 in 8138280

if (gui.mouse_focus_mask == 0 && over != gui.mouse_over) {
if (gui.mouse_over) {
_gui_call_notification(gui.mouse_over, Control::NOTIFICATION_MOUSE_EXIT);
}
_gui_cancel_tooltip();
if (over) {
_gui_call_notification(over, Control::NOTIFICATION_MOUSE_ENTER);
}
}

You can see that mouse_exit is called when the control is different, not just when the mouse leaves the rect. So this could potentially be changed so that exit is only called when over != gui.mouse_over AND gui.mouse_over is not a parent of over

@Jattmackson
Copy link

It is really weird, check this out.

VXSC2SK3tm.mp4
But the behaviour is completely different for labels and text boxes:

R2VCszbmv8.mp4
Tested on v4.0.dev.custom_build [8138280] btw

Here is the code which calls mouse enter/exit notifications:

godot/scene/main/viewport.cpp

Lines 1577 to 1587 in 8138280

if (gui.mouse_focus_mask == 0 && over != gui.mouse_over) {
if (gui.mouse_over) {
_gui_call_notification(gui.mouse_over, Control::NOTIFICATION_MOUSE_EXIT);
}
_gui_cancel_tooltip();
if (over) {
_gui_call_notification(over, Control::NOTIFICATION_MOUSE_ENTER);
}
}

You can see that mouse_exit is called when the control is different, not just when the mouse leaves the rect. So this could potentially be changed so that exit is only called when over != gui.mouse_over AND gui.mouse_over is not a parent of over

The difference here is because the default "mouse filter" for label is "ignore" whereas the default filter for buttons and textedits is "stop".

Filter "ignore" will never trip mouse_entered but will not trip mouse_exited for its parents
Filter "stop" will trip mouse_entered but also mouse_exited for its parents

Filter "pass" which is supposed to give the intended behaviour is much closer, it trips mouse_entered for the child and then trips mouse_exited and immediately mouse_entered for the parent.
***** (I believe this is a bug it should not trip mouse_exited at all here) *****

(Bug aside), the issue here is both that "mouse filter" is not intuitive for newcomers, and the intuitive outcome of "mouse_entered" and "mouse_exited" is different in different contexts.

For a menu like this setting all the children's mouse filters to "ignore"(ONLY IF it is not an interactive element) or "pass" will give more intuitive behaviour.
Use mouse filter "stop" for when two different menus could overlap and you want the program to realise that it has left a menu.

Because the default mouse_filter settings are different for everything are different, Complex menus which can't overlap it might be worth using my workaround above as it is probably easier than going through every child and changing the mouse_filters.

@maximinus
Copy link

I am also (on v3.4.stable.official [206ba70]) getting this issue. In my case, it arose because the tooltip itself was drawn over the control with the mouse_entered() signal.

You can solve in this case by making sure the new tooltip control does not overlap the detecting control. It is not ideal but is a possible solution.

@KoBeWi
Copy link
Member

KoBeWi commented Jan 12, 2022

Seems to me more like documentation issue than a bug. The signal will be emitted when the cursor stops hovering over the Control. This happens not only when it leaves the area, but also when another control gets hovered.

Here's a workaround for those who want to ignore higher controls. It will work in all cases, even when the control is rotated etc.:

func _on_mouse_exited() -> void:
	if not Rect2(Vector2(), rect_size).has_point(get_local_mouse_position()):
		# stuff

EDIT:
Actually it won't work in such case:
image
You can leave the gray rectangle via the red one, without triggering the condition 🤔
But given how this signal works, it might be technically difficult to solve. It's better to use the _process() solution 2 comments above.

@IvanIG3
Copy link

IvanIG3 commented Jan 12, 2022

Seems to me more like documentation issue than a bug. The signal will be emitted when the cursor stops hovering over the Control. This happens not only when it leaves the area, but also when another control gets hovered.

Here's a workaround for those who want to ignore higher controls. It will work in all cases, even when the control is rotated etc.:

func _on_mouse_exited() -> void:
	if not Rect2(Vector2(), rect_size).has_point(get_local_mouse_position()):
		# stuff

I found one case where this issue could cause more trouble than just ignoring the exited signal:

We have a parent control "A", with mouse filter PASS, and we have a child control "B", inside the node "A", with mouse filter PASS. In that case, when hovering B, the node A emits the exited signal, then B emits the signal entered, and then, the node B emits the signal entered too. If we print this in the console, we will see this:

Parent exited
Child entered
Parent entered

So, to workaround this, we have to ignore the signal emitted by the child B, and also the signal emitted by the parent, but only if the mouse were inside the parent and still inside its rect size.

@trytryty1
Copy link

Still having this issue in 2023

@YuriSizov
Copy link
Contributor

@trytryty1 Which version of Godot are you using? Can you upload a reproduction project that demonstrates that issue?

@RonYanDaik
Copy link

RonYanDaik commented Sep 25, 2023

Which version of Godot are you using? Can you upload a reproduction project that demonstrates that issue?

4.1

project:
multiplayer_bomber.zip

Video:

mousebug.mp4

@kitbdev
Copy link
Contributor

kitbdev commented Nov 13, 2023

@trytryty1 @RonYanDaik
This should be resolved as of #84547 (4.2). The behavior was changed so that only the node that was entered/exited receives those signals.

@Epenko1337
Copy link

lol still not working properly

@kitbdev
Copy link
Contributor

kitbdev commented Nov 14, 2023

@Epenko1337
Did you test it on the new 4.2 beta 6?
And is it the same problem as before or is it different at all?

I've tested the latest project above and it seems to be working as intended in beta 6.

@BendySonic
Copy link

BendySonic commented Feb 18, 2024

Still not working... Never will... Godot 4.2 :p

@kitbdev
Copy link
Contributor

kitbdev commented Feb 18, 2024

Hi @BendySonic,
What is the issue you are having? Can you provide reproduction steps?

@Epenko1337
Copy link

Epenko1337 commented May 4, 2024

@Epenko1337 Did you test it on the new 4.2 beta 6? And is it the same problem as before or is it different at all?

I've tested the latest project above and it seems to be working as intended in beta 6.

Sadly but i'm using stable 3.5, so have no way to check out. Anyway i just fixed this on my own in my project with some workaround.

@botin123
Copy link

The problem seems to be focus. When i turned off focus on my buttons, mouse_exited did not fire when clicking them.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging a pull request may close this issue.