Repository: godotengine/godot (language: cpp)

Issue:
The breakpoint hover indicator doesn't overlap with its click detection
### Godot version

4.0.alpha16

### System information

Ubuntu 20.04.3 LTS

### Issue description

1. One pixel to the right of a breakpoint, the cursor becomes an arrow, but a breakpoint can still be added.

![image](https://user-images.githubusercontent.com/85438892/189096121-f4681493-a479-4d6f-a2e8-b9a743003832.png)

2. ~This creates a breakpoint on the top line, even though the indicator is on the bottom line:~

![image](https://user-images.githubusercontent.com/85438892/189095820-a1a5863d-f4de-464f-a936-07b257a3f5d1.png)

3. The indicator lags behind sometimes when the mouse exits the CodeEdit, or stays forever if the caret is not blinking.

4. ~Creating a breakpoint is possible if you go below the last line or above the first line, but the hover indicator doesn't reflect it.~

5. ~The hover indicator also activates when the mouse is outside of the main window on partially hidden lines:~

![image](https://user-images.githubusercontent.com/85438892/190708964-e4187ca4-f4dc-4b56-aa35-f7f71572e461.png)

6. ~It doesn't work with word wrap.~

### Steps to reproduce

Place your mouse exactly like in the photos

### Minimal reproduction project

N/A


Discussion / hints:
In general, I suggest making the hover area for breakpoint buttons span the whole line's height. This makes them easier to click quickly, without having to spend too much time aiming.
That is the case presently, but sometimes the indicator doesn't show the correct line where a breakpoint is about to be toggled.
Point 3 is caused because the text editor doesn't redraw when the cursor exits it. It usually disappears shortly after because the caret blink causes a redraw.

This also causes the bookmark indicator to take a brief moment to update after pressing Shift.
Point 3 has been reintroduced at some point during the 4.4 development cycle. Even caret blinking doesn't cause the redraw this time though.
- Related https://github.com/godotengine/godot/issues/93036
- Some of these were fixed in https://github.com/godotengine/godot/pull/92865

The indicator should now match where the breakpoint will be placed. So points 2, 4, and 6 are fixed.
Though for point 4 maybe it should not allow placing a breakpoint if it is past the last line and above the first?
I think point 5 is fixed as well, though it is harder to see because of point 3.
Just tested and indeed, what you've mentioned has been addressed. I scratched these out in the issue now.

At the same time, 3 has gotten much worse, and I found that there's an instance of 1 that's still problematic so I've edited it. So only these two issues remain. 🎉 

Fix patch:
diff --git a/scene/gui/code_edit.cpp b/scene/gui/code_edit.cpp
index 7f8b4dca7862..66080e510351 100644
--- a/scene/gui/code_edit.cpp
+++ b/scene/gui/code_edit.cpp
@@ -275,7 +275,6 @@ void CodeEdit::_notification(int p_what) {
 		} break;
 
 		case NOTIFICATION_MOUSE_EXIT: {
-			queue_redraw();
 			symbol_tooltip_timer->stop();
 		} break;
 	}
diff --git a/scene/gui/text_edit.cpp b/scene/gui/text_edit.cpp
index 85a6dce028ee..4ef4959162cc 100644
--- a/scene/gui/text_edit.cpp
+++ b/scene/gui/text_edit.cpp
@@ -1675,6 +1675,10 @@ void TextEdit::_notification(int p_what) {
 				drag_caret_force_displayed = false;
 				queue_redraw();
 			}
+			if (hovered_gutter != Vector2i(-1, -1)) {
+				hovered_gutter = Vector2i(-1, -1);
+				queue_redraw();
+			}
 		} break;
 	}
 }
@@ -1843,18 +1847,18 @@ void TextEdit::gui_input(const Ref<InputEvent> &p_gui_input) {
 				int col = pos.x;
 
 				// Gutters.
+				Vector2i current_hovered_gutter = _get_hovered_gutter(mpos);
+				if (current_hovered_gutter != hovered_gutter) {
+					hovered_gutter = current_hovered_gutter;
+					queue_redraw();
+				}
+				if (hovered_gutter != Vector2i(-1, -1)) {
+					emit_signal(SNAME("gutter_clicked"), hovered_gutter.y, hovered_gutter.x);
+					return;
+				}
 				int left_margin = theme_cache.style_normal->get_margin(SIDE_LEFT);
-				for (int i = 0; i < gutters.size(); i++) {
-					if (!gutters[i].draw || gutters[i].width <= 0) {
-						continue;
-					}
-
-					if (mpos.x >= left_margin && mpos.x <= left_margin + gutters[i].width) {
-						emit_signal(SNAME("gutter_clicked"), line, i);
-						return;
-					}
-
-					left_margin += gutters[i].width;
+				if (mpos.x < left_margin + gutters_width + gutter_padding) {
+					return;
 				}
 
 				// Minimap.
@@ -2066,27 +2070,8 @@ void TextEdit::gui_input(const Ref<InputEvent> &p_gui_input) {
 			}
 		}
 
-		// Check if user is hovering a different gutter, and update if yes.
-		Vector2i current_hovered_gutter = Vector2i(-1, -1);
-
-		int left_margin = theme_cache.style_normal->get_margin(SIDE_LEFT);
-		if (mpos.x <= left_margin + gutters_width + gutter_padding) {
-			int hovered_row = get_line_column_at_pos(mpos).y;
-			for (int i = 0; i < gutters.size(); i++) {
-				if (!gutters[i].draw || gutters[i].width <= 0) {
-					continue;
-				}
-
-				if (mpos.x >= left_margin && mpos.x < left_margin + gutters[i].width) {
-					// We are in this gutter i's horizontal area.
-					current_hovered_gutter = Vector2i(i, hovered_row);
-					break;
-				}
-
-				left_margin += gutters[i].width;
-			}
-		}
-
+		// Update hovered gutter.
+		Vector2i current_hovered_gutter = _get_hovered_gutter(mpos);
 		if (current_hovered_gutter != hovered_gutter) {
 			hovered_gutter = current_hovered_gutter;
 			queue_redraw();
@@ -3115,24 +3100,16 @@ void TextEdit::drop_data(const Point2 &p_point, const Variant &p_data) {
 }
 
 Control::CursorShape TextEdit::get_cursor_shape(const Point2 &p_pos) const {
-	Point2i pos = get_line_column_at_pos(p_pos);
-	int row = pos.y;
-
-	int left_margin = theme_cache.style_normal->get_margin(SIDE_LEFT);
-	int gutter = left_margin + gutters_width;
-	if (p_pos.x < gutter) {
-		for (int i = 0; i < gutters.size(); i++) {
-			if (!gutters[i].draw) {
-				continue;
-			}
-
-			if (p_pos.x >= left_margin && p_pos.x < left_margin + gutters[i].width) {
-				if (gutters[i].clickable || is_line_gutter_clickable(row, i)) {
-					return CURSOR_POINTING_HAND;
-				}
-			}
-			left_margin += gutters[i].width;
+	Vector2i current_hovered_gutter = _get_hovered_gutter(p_pos);
+	if (current_hovered_gutter != Vector2i(-1, -1)) {
+		if (gutters[current_hovered_gutter.x].clickable || is_line_gutter_clickable(current_hovered_gutter.y, current_hovered_gutter.x)) {
+			return CURSOR_POINTING_HAND;
+		} else {
+			return CURSOR_ARROW;
 		}
+	}
+	int left_margin = theme_cache.style_normal->get_margin(SIDE_LEFT);
+	if (p_pos.x < left_margin + gutters_width + gutter_padding) {
 		return CURSOR_ARROW;
 	}
 
@@ -8321,9 +8298,35 @@ void TextEdit::_update_gutter_width() {
 	if (gutters_width > 0) {
 		gutter_padding = 2;
 	}
+	if (get_viewport()) {
+		hovered_gutter = _get_hovered_gutter(get_local_mouse_position());
+	}
 	queue_redraw();
 }
 
+Vector2i TextEdit::_get_hovered_gutter(const Point2 &p_mouse_pos) const {
+	int left_margin = theme_cache.style_normal->get_margin(SIDE_LEFT);
+	if (p_mouse_pos.x > left_margin + gutters_width + gutter_padding) {
+		return Vector2i(-1, -1);
+	}
+	int hovered_row = get_line_column_at_pos(p_mouse_pos, false).y;
+	if (hovered_row == -1) {
+		return Vector2i(-1, -1);
+	}
+	for (int i = 0; i < gutters.size(); i++) {
+		if (!gutters[i].draw || gutters[i].width <= 0) {
+			continue;
+		}
+
+		if (p_mouse_pos.x >= left_margin && p_mouse_pos.x < left_margin + gutters[i].width) {
+			return Vector2i(i, hovered_row);
+		}
+
+		left_margin += gutters[i].width;
+	}
+	return Vector2i(-1, -1);
+}
+
 /* Syntax highlighting. */
 Vector<Pair<int64_t, Color>> TextEdit::_get_line_syntax_highlighting(int p_line) {
 	if (syntax_highlighter.is_null() || setting_text) {
diff --git a/scene/gui/text_edit.h b/scene/gui/text_edit.h
index ef5110963023..335fe9bf03e6 100644
--- a/scene/gui/text_edit.h
+++ b/scene/gui/text_edit.h
@@ -565,6 +565,7 @@ class TextEdit : public Control {
 	Vector2i hovered_gutter = Vector2i(-1, -1); // X = gutter index, Y = row.
 
 	void _update_gutter_width();
+	Vector2i _get_hovered_gutter(const Point2 &p_mouse_pos) const;
 
 	/* Syntax highlighting. */
 	Ref<SyntaxHighlighter> syntax_highlighter;


Test patch:
diff --git a/tests/scene/test_text_edit.h b/tests/scene/test_text_edit.h
index c41eebdf3a4a..d7192def6212 100644
--- a/tests/scene/test_text_edit.h
+++ b/tests/scene/test_text_edit.h
@@ -8151,6 +8151,93 @@ TEST_CASE("[SceneTree][TextEdit] gutters") {
 		// Merging tested via CodeEdit gutters.
 	}
 
+	SUBCASE("[TextEdit] gutter mouse") {
+		DisplayServerMock *DS = (DisplayServerMock *)(DisplayServer::get_singleton());
+		// Set size for mouse input.
+		text_edit->set_size(Size2(200, 200));
+
+		text_edit->set_text("test1\ntest2\ntest3\ntest4");
+		text_edit->grab_focus();
+
+		text_edit->add_gutter();
+		text_edit->set_gutter_name(0, "test_gutter");
+		text_edit->set_gutter_width(0, 10);
+		text_edit->set_gutter_clickable(0, true);
+
+		text_edit->add_gutter();
+		text_edit->set_gutter_name(1, "test_gutter_not_clickable");
+		text_edit->set_gutter_width(1, 10);
+		text_edit->set_gutter_clickable(1, false);
+
+		text_edit->add_gutter();
+		CHECK(text_edit->get_gutter_count() == 3);
+		text_edit->set_gutter_name(2, "test_gutter_3");
+		text_edit->set_gutter_width(2, 10);
+		text_edit->set_gutter_clickable(2, true);
+
+		MessageQueue::get_singleton()->flush();
+		const int line_height = text_edit->get_line_height();
+
+		// Defaults to none.
+		CHECK(text_edit->get_hovered_gutter() == Vector2i(-1, -1));
+		CHECK(DS->get_cursor_shape() == DisplayServer::CURSOR_ARROW);
+
+		// Hover over gutter.
+		SEND_GUI_MOUSE_MOTION_EVENT(Point2(5, line_height + line_height / 2), MouseButtonMask::NONE, Key::NONE);
+		CHECK(text_edit->get_hovered_gutter() == Vector2i(0, 1));
+		SIGNAL_CHECK_FALSE("gutter_clicked");
+		CHECK(DS->get_cursor_shape() == DisplayServer::CURSOR_POINTING_HAND);
+
+		// Click on gutter.
+		SEND_GUI_MOUSE_BUTTON_EVENT(Point2(5, line_height / 2), MouseButton::LEFT, MouseButtonMask::LEFT, Key::NONE);
+		CHECK(text_edit->get_hovered_gutter() == Vector2i(0, 0));
+		SIGNAL_CHECK("gutter_clicked", build_array(build_array(0, 0)));
+
+		// Click on gutter on another line.
+		SEND_GUI_MOUSE_BUTTON_EVENT(Point2(5, line_height * 3 + line_height / 2), MouseButton::LEFT, MouseButtonMask::LEFT, Key::NONE);
+		CHECK(text_edit->get_hovered_gutter() == Vector2i(0, 3));
+		SIGNAL_CHECK("gutter_clicked", build_array(build_array(3, 0)));
+
+		// Unclickable gutter can be hovered.
+		SEND_GUI_MOUSE_MOTION_EVENT(Point2(15, line_height + line_height / 2), MouseButtonMask::NONE, Key::NONE);
+		CHECK(text_edit->get_hovered_gutter() == Vector2i(1, 1));
+		SIGNAL_CHECK_FALSE("gutter_clicked");
+		CHECK(DS->get_cursor_shape() == DisplayServer::CURSOR_ARROW);
+
+		// Unclickable gutter can be clicked.
+		SEND_GUI_MOUSE_BUTTON_EVENT(Point2(15, line_height * 2 + line_height / 2), MouseButton::LEFT, MouseButtonMask::LEFT, Key::NONE);
+		CHECK(text_edit->get_hovered_gutter() == Vector2i(1, 2));
+		SIGNAL_CHECK("gutter_clicked", build_array(build_array(2, 1)));
+		CHECK(DS->get_cursor_shape() == DisplayServer::CURSOR_ARROW);
+
+		// Hover past last line.
+		SEND_GUI_MOUSE_MOTION_EVENT(Point2(5, line_height * 5), MouseButtonMask::NONE, Key::NONE);
+		CHECK(text_edit->get_hovered_gutter() == Vector2i(-1, -1));
+		SIGNAL_CHECK_FALSE("gutter_clicked");
+		CHECK(DS->get_cursor_shape() == DisplayServer::CURSOR_ARROW);
+
+		// Click on gutter past last line.
+		SEND_GUI_MOUSE_BUTTON_EVENT(Point2(5, line_height * 5), MouseButton::LEFT, MouseButtonMask::LEFT, Key::NONE);
+		CHECK(text_edit->get_hovered_gutter() == Vector2i(-1, -1));
+		SIGNAL_CHECK_FALSE("gutter_clicked");
+
+		// Mouse exit resets hover.
+		SEND_GUI_MOUSE_MOTION_EVENT(Point2(5, line_height + line_height / 2), MouseButtonMask::NONE, Key::NONE);
+		CHECK(text_edit->get_hovered_gutter() == Vector2i(0, 1));
+		SEND_GUI_MOUSE_MOTION_EVENT(Point2(-1, -1), MouseButtonMask::NONE, Key::NONE);
+		CHECK(text_edit->get_hovered_gutter() == Vector2i(-1, -1));
+
+		// Removing gutter updates hover.
+		SEND_GUI_MOUSE_MOTION_EVENT(Point2(25, line_height + line_height / 2), MouseButtonMask::NONE, Key::NONE);
+		CHECK(text_edit->get_hovered_gutter() == Vector2i(2, 1));
+		text_edit->remove_gutter(2);
+		CHECK(text_edit->get_hovered_gutter() == Vector2i(-1, -1));
+
+		// Updating size updates hover.
+		text_edit->set_gutter_width(1, 20);
+		CHECK(text_edit->get_hovered_gutter() == Vector2i(1, 1));
+	}
+
 	SIGNAL_UNWATCH(text_edit, "gutter_clicked");
 	SIGNAL_UNWATCH(text_edit, "gutter_added");
 	SIGNAL_UNWATCH(text_edit, "gutter_removed");