1/**************************************************************************/
2/* editor_toaster.cpp */
3/**************************************************************************/
4/* This file is part of: */
5/* GODOT ENGINE */
6/* https://godotengine.org */
7/**************************************************************************/
8/* Copyright (c) 2014-present Godot Engine contributors (see AUTHORS.md). */
9/* Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur. */
10/* */
11/* Permission is hereby granted, free of charge, to any person obtaining */
12/* a copy of this software and associated documentation files (the */
13/* "Software"), to deal in the Software without restriction, including */
14/* without limitation the rights to use, copy, modify, merge, publish, */
15/* distribute, sublicense, and/or sell copies of the Software, and to */
16/* permit persons to whom the Software is furnished to do so, subject to */
17/* the following conditions: */
18/* */
19/* The above copyright notice and this permission notice shall be */
20/* included in all copies or substantial portions of the Software. */
21/* */
22/* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, */
23/* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF */
24/* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. */
25/* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY */
26/* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, */
27/* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE */
28/* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */
29/**************************************************************************/
30
31#include "editor_toaster.h"
32
33#include "editor/editor_scale.h"
34#include "editor/editor_settings.h"
35#include "editor/editor_string_names.h"
36#include "scene/gui/button.h"
37#include "scene/gui/label.h"
38#include "scene/gui/panel_container.h"
39#include "scene/resources/style_box_flat.h"
40
41EditorToaster *EditorToaster::singleton = nullptr;
42
43void EditorToaster::_notification(int p_what) {
44 switch (p_what) {
45 case NOTIFICATION_INTERNAL_PROCESS: {
46 double delta = get_process_delta_time();
47
48 // Check if one element is hovered, if so, don't elapse time.
49 bool hovered = false;
50 for (const KeyValue<Control *, Toast> &element : toasts) {
51 if (Rect2(Vector2(), element.key->get_size()).has_point(element.key->get_local_mouse_position())) {
52 hovered = true;
53 break;
54 }
55 }
56
57 // Elapses the time and remove toasts if needed.
58 if (!hovered) {
59 for (const KeyValue<Control *, Toast> &element : toasts) {
60 if (!element.value.popped || element.value.duration <= 0) {
61 continue;
62 }
63 toasts[element.key].remaining_time -= delta;
64 if (toasts[element.key].remaining_time < 0) {
65 close(element.key);
66 }
67 element.key->queue_redraw();
68 }
69 } else {
70 // Reset the timers when hovered.
71 for (const KeyValue<Control *, Toast> &element : toasts) {
72 if (!element.value.popped || element.value.duration <= 0) {
73 continue;
74 }
75 toasts[element.key].remaining_time = element.value.duration;
76 element.key->queue_redraw();
77 }
78 }
79
80 // Change alpha over time.
81 bool needs_update = false;
82 for (const KeyValue<Control *, Toast> &element : toasts) {
83 Color modulate_fade = element.key->get_modulate();
84
85 // Change alpha over time.
86 if (element.value.popped && modulate_fade.a < 1.0) {
87 modulate_fade.a += delta * 3;
88 element.key->set_modulate(modulate_fade);
89 } else if (!element.value.popped && modulate_fade.a > 0.0) {
90 modulate_fade.a -= delta * 2;
91 element.key->set_modulate(modulate_fade);
92 }
93
94 // Hide element if it is not visible anymore.
95 if (modulate_fade.a <= 0.0 && element.key->is_visible()) {
96 element.key->hide();
97 needs_update = true;
98 } else if (modulate_fade.a > 0.0 && !element.key->is_visible()) {
99 element.key->show();
100 needs_update = true;
101 }
102 }
103
104 if (needs_update) {
105 _update_vbox_position();
106 _update_disable_notifications_button();
107 main_button->queue_redraw();
108 }
109 } break;
110
111 case NOTIFICATION_ENTER_TREE:
112 case NOTIFICATION_THEME_CHANGED: {
113 if (vbox_container->is_visible()) {
114 main_button->set_icon(get_editor_theme_icon(SNAME("Notification")));
115 } else {
116 main_button->set_icon(get_editor_theme_icon(SNAME("NotificationDisabled")));
117 }
118 disable_notifications_button->set_icon(get_editor_theme_icon(SNAME("NotificationDisabled")));
119
120 // Styleboxes background.
121 info_panel_style_background->set_bg_color(get_theme_color(SNAME("base_color"), EditorStringName(Editor)));
122
123 warning_panel_style_background->set_bg_color(get_theme_color(SNAME("base_color"), EditorStringName(Editor)));
124 warning_panel_style_background->set_border_color(get_theme_color(SNAME("warning_color"), EditorStringName(Editor)));
125
126 error_panel_style_background->set_bg_color(get_theme_color(SNAME("base_color"), EditorStringName(Editor)));
127 error_panel_style_background->set_border_color(get_theme_color(SNAME("error_color"), EditorStringName(Editor)));
128
129 // Styleboxes progress.
130 info_panel_style_progress->set_bg_color(get_theme_color(SNAME("base_color"), EditorStringName(Editor)).lightened(0.03));
131
132 warning_panel_style_progress->set_bg_color(get_theme_color(SNAME("base_color"), EditorStringName(Editor)).lightened(0.03));
133 warning_panel_style_progress->set_border_color(get_theme_color(SNAME("warning_color"), EditorStringName(Editor)));
134
135 error_panel_style_progress->set_bg_color(get_theme_color(SNAME("base_color"), EditorStringName(Editor)).lightened(0.03));
136 error_panel_style_progress->set_border_color(get_theme_color(SNAME("error_color"), EditorStringName(Editor)));
137
138 main_button->queue_redraw();
139 disable_notifications_button->queue_redraw();
140 } break;
141
142 case NOTIFICATION_TRANSFORM_CHANGED: {
143 _update_vbox_position();
144 _update_disable_notifications_button();
145 } break;
146 }
147}
148
149void EditorToaster::_error_handler(void *p_self, const char *p_func, const char *p_file, int p_line, const char *p_error, const char *p_errorexp, bool p_editor_notify, ErrorHandlerType p_type) {
150 // This may be called from a thread. Since we will deal with non-thread-safe elements,
151 // we have to put it in the queue for safety.
152 callable_mp_static(&EditorToaster::_error_handler_impl).bind(p_file, p_line, p_error, p_errorexp, p_editor_notify, p_type).call_deferred();
153}
154
155void EditorToaster::_error_handler_impl(const String &p_file, int p_line, const String &p_error, const String &p_errorexp, bool p_editor_notify, int p_type) {
156 if (!EditorToaster::get_singleton() || !EditorToaster::get_singleton()->is_inside_tree()) {
157 return;
158 }
159
160#ifdef DEV_ENABLED
161 bool in_dev = true;
162#else
163 bool in_dev = false;
164#endif
165
166 int show_all_setting = EDITOR_GET("interface/editor/show_internal_errors_in_toast_notifications");
167
168 if (p_editor_notify || (show_all_setting == 0 && in_dev) || show_all_setting == 1) {
169 String err_str = !p_errorexp.is_empty() ? p_errorexp : p_error;
170 String tooltip_str = p_file + ":" + itos(p_line);
171
172 if (!p_editor_notify) {
173 if (p_type == ERR_HANDLER_WARNING) {
174 err_str = "INTERNAL WARNING: " + err_str;
175 } else {
176 err_str = "INTERNAL ERROR: " + err_str;
177 }
178 }
179
180 Severity severity = ((ErrorHandlerType)p_type == ERR_HANDLER_WARNING) ? SEVERITY_WARNING : SEVERITY_ERROR;
181 EditorToaster::get_singleton()->popup_str(err_str, severity, tooltip_str);
182 }
183}
184
185void EditorToaster::_update_vbox_position() {
186 // This is kind of a workaround because it's hard to keep the VBox anchroed to the bottom.
187 vbox_container->set_size(Vector2());
188 vbox_container->set_position(get_global_position() - vbox_container->get_size() + Vector2(get_size().x, -5 * EDSCALE));
189}
190
191void EditorToaster::_update_disable_notifications_button() {
192 bool any_visible = false;
193 for (KeyValue<Control *, Toast> element : toasts) {
194 if (element.key->is_visible()) {
195 any_visible = true;
196 break;
197 }
198 }
199
200 if (!any_visible || !vbox_container->is_visible()) {
201 disable_notifications_panel->hide();
202 } else {
203 disable_notifications_panel->show();
204 disable_notifications_panel->set_position(get_global_position() + Vector2(5 * EDSCALE, -disable_notifications_panel->get_minimum_size().y) + Vector2(get_size().x, -5 * EDSCALE));
205 }
206}
207
208void EditorToaster::_auto_hide_or_free_toasts() {
209 // Hide or free old temporary items.
210 int visible_temporary = 0;
211 int temporary = 0;
212 LocalVector<Control *> to_delete;
213 for (int i = vbox_container->get_child_count() - 1; i >= 0; i--) {
214 Control *control = Object::cast_to<Control>(vbox_container->get_child(i));
215 if (toasts[control].duration <= 0) {
216 continue; // Ignore non-temporary toasts.
217 }
218
219 temporary++;
220 if (control->is_visible()) {
221 visible_temporary++;
222 }
223
224 // Hide
225 if (visible_temporary > max_temporary_count) {
226 close(control);
227 }
228
229 // Free
230 if (temporary > max_temporary_count * 2) {
231 to_delete.push_back(control);
232 }
233 }
234
235 // Delete the control right away (removed as child) as it might cause issues otherwise when iterative over the vbox_container children.
236 for (Control *c : to_delete) {
237 vbox_container->remove_child(c);
238 c->queue_free();
239 toasts.erase(c);
240 }
241
242 if (toasts.is_empty()) {
243 main_button->set_tooltip_text(TTR("No notifications."));
244 main_button->set_modulate(Color(0.5, 0.5, 0.5));
245 main_button->set_disabled(true);
246 } else {
247 main_button->set_tooltip_text(TTR("Show notifications."));
248 main_button->set_modulate(Color(1, 1, 1));
249 main_button->set_disabled(false);
250 }
251}
252
253void EditorToaster::_draw_button() {
254 bool has_one = false;
255 Severity highest_severity = SEVERITY_INFO;
256 for (const KeyValue<Control *, Toast> &element : toasts) {
257 if (!element.key->is_visible()) {
258 continue;
259 }
260 has_one = true;
261 if (element.value.severity > highest_severity) {
262 highest_severity = element.value.severity;
263 }
264 }
265
266 if (!has_one) {
267 return;
268 }
269
270 Color color;
271 real_t button_radius = main_button->get_size().x / 8;
272 switch (highest_severity) {
273 case SEVERITY_INFO:
274 color = get_theme_color(SNAME("accent_color"), EditorStringName(Editor));
275 break;
276 case SEVERITY_WARNING:
277 color = get_theme_color(SNAME("warning_color"), EditorStringName(Editor));
278 break;
279 case SEVERITY_ERROR:
280 color = get_theme_color(SNAME("error_color"), EditorStringName(Editor));
281 break;
282 default:
283 break;
284 }
285 main_button->draw_circle(Vector2(button_radius * 2, button_radius * 2), button_radius, color);
286}
287
288void EditorToaster::_draw_progress(Control *panel) {
289 if (toasts.has(panel) && toasts[panel].remaining_time > 0 && toasts[panel].duration > 0) {
290 Size2 size = panel->get_size();
291 size.x *= MIN(1, Math::remap(toasts[panel].remaining_time, 0, toasts[panel].duration, 0, 2));
292
293 Ref<StyleBoxFlat> stylebox;
294 switch (toasts[panel].severity) {
295 case SEVERITY_INFO:
296 stylebox = info_panel_style_progress;
297 break;
298 case SEVERITY_WARNING:
299 stylebox = warning_panel_style_progress;
300 break;
301 case SEVERITY_ERROR:
302 stylebox = error_panel_style_progress;
303 break;
304 default:
305 break;
306 }
307 panel->draw_style_box(stylebox, Rect2(Vector2(), size));
308 }
309}
310
311void EditorToaster::_set_notifications_enabled(bool p_enabled) {
312 vbox_container->set_visible(p_enabled);
313 if (p_enabled) {
314 main_button->set_icon(get_editor_theme_icon(SNAME("Notification")));
315 } else {
316 main_button->set_icon(get_editor_theme_icon(SNAME("NotificationDisabled")));
317 }
318 _update_disable_notifications_button();
319}
320
321void EditorToaster::_repop_old() {
322 // Repop olds, up to max_temporary_count
323 bool needs_update = false;
324 int visible_count = 0;
325 for (int i = vbox_container->get_child_count() - 1; i >= 0; i--) {
326 Control *control = Object::cast_to<Control>(vbox_container->get_child(i));
327 if (!control->is_visible()) {
328 control->show();
329 toasts[control].remaining_time = toasts[control].duration;
330 toasts[control].popped = true;
331 needs_update = true;
332 }
333 visible_count++;
334 if (visible_count >= max_temporary_count) {
335 break;
336 }
337 }
338 if (needs_update) {
339 _update_vbox_position();
340 _update_disable_notifications_button();
341 main_button->queue_redraw();
342 }
343}
344
345Control *EditorToaster::popup(Control *p_control, Severity p_severity, double p_time, String p_tooltip) {
346 // Create the panel according to the severity.
347 PanelContainer *panel = memnew(PanelContainer);
348 panel->set_tooltip_text(p_tooltip);
349 switch (p_severity) {
350 case SEVERITY_INFO:
351 panel->add_theme_style_override("panel", info_panel_style_background);
352 break;
353 case SEVERITY_WARNING:
354 panel->add_theme_style_override("panel", warning_panel_style_background);
355 break;
356 case SEVERITY_ERROR:
357 panel->add_theme_style_override("panel", error_panel_style_background);
358 break;
359 default:
360 break;
361 }
362 panel->set_modulate(Color(1, 1, 1, 0));
363 panel->connect("draw", callable_mp(this, &EditorToaster::_draw_progress).bind(panel));
364
365 // Horizontal container.
366 HBoxContainer *hbox_container = memnew(HBoxContainer);
367 hbox_container->set_h_size_flags(SIZE_EXPAND_FILL);
368 panel->add_child(hbox_container);
369
370 // Content control.
371 p_control->set_h_size_flags(SIZE_EXPAND_FILL);
372 hbox_container->add_child(p_control);
373
374 // Close button.
375 if (p_time > 0.0) {
376 Button *close_button = memnew(Button);
377 close_button->set_flat(true);
378 close_button->set_icon(get_editor_theme_icon(SNAME("Close")));
379 close_button->connect("pressed", callable_mp(this, &EditorToaster::close).bind(panel));
380 close_button->connect("theme_changed", callable_mp(this, &EditorToaster::_close_button_theme_changed).bind(close_button));
381 hbox_container->add_child(close_button);
382 }
383
384 toasts[panel].severity = p_severity;
385 if (p_time > 0.0) {
386 toasts[panel].duration = p_time;
387 toasts[panel].remaining_time = p_time;
388 } else {
389 toasts[panel].duration = -1.0;
390 }
391 toasts[panel].popped = true;
392 vbox_container->add_child(panel);
393 _auto_hide_or_free_toasts();
394 _update_vbox_position();
395 _update_disable_notifications_button();
396 main_button->queue_redraw();
397
398 return panel;
399}
400
401void EditorToaster::popup_str(String p_message, Severity p_severity, String p_tooltip) {
402 if (is_processing_error) {
403 return;
404 }
405
406 // Since "_popup_str" adds nodes to the tree, and since the "add_child" method is not
407 // thread-safe, it's better to defer the call to the next cycle to be thread-safe.
408 is_processing_error = true;
409 call_deferred(SNAME("_popup_str"), p_message, p_severity, p_tooltip);
410 is_processing_error = false;
411}
412
413void EditorToaster::_popup_str(String p_message, Severity p_severity, String p_tooltip) {
414 is_processing_error = true;
415 // Check if we already have a popup with the given message.
416 Control *control = nullptr;
417 for (KeyValue<Control *, Toast> element : toasts) {
418 if (element.value.message == p_message && element.value.severity == p_severity && element.value.tooltip == p_tooltip) {
419 control = element.key;
420 break;
421 }
422 }
423
424 // Create a new message if needed.
425 if (control == nullptr) {
426 HBoxContainer *hb = memnew(HBoxContainer);
427 hb->add_theme_constant_override("separation", 0);
428
429 Label *label = memnew(Label);
430 hb->add_child(label);
431
432 Label *count_label = memnew(Label);
433 hb->add_child(count_label);
434
435 control = popup(hb, p_severity, default_message_duration, p_tooltip);
436 toasts[control].message = p_message;
437 toasts[control].tooltip = p_tooltip;
438 toasts[control].count = 1;
439 toasts[control].message_label = label;
440 toasts[control].message_count_label = count_label;
441 } else {
442 if (toasts[control].popped) {
443 toasts[control].count += 1;
444 } else {
445 toasts[control].count = 1;
446 }
447 toasts[control].remaining_time = toasts[control].duration;
448 toasts[control].popped = true;
449 control->show();
450 vbox_container->move_child(control, vbox_container->get_child_count());
451 _auto_hide_or_free_toasts();
452 _update_vbox_position();
453 _update_disable_notifications_button();
454 main_button->queue_redraw();
455 }
456
457 // Retrieve the label back, then update the text.
458 Label *message_label = toasts[control].message_label;
459 ERR_FAIL_NULL(message_label);
460 message_label->set_text(p_message);
461 message_label->set_text_overrun_behavior(TextServer::OVERRUN_NO_TRIMMING);
462 message_label->set_custom_minimum_size(Size2());
463
464 Size2i size = message_label->get_combined_minimum_size();
465 int limit_width = get_viewport_rect().size.x / 2; // Limit label size to half the viewport size.
466 if (size.x > limit_width) {
467 message_label->set_text_overrun_behavior(TextServer::OVERRUN_TRIM_ELLIPSIS);
468 message_label->set_custom_minimum_size(Size2(limit_width, 0));
469 }
470
471 // Retrieve the count label back, then update the text.
472 Label *message_count_label = toasts[control].message_count_label;
473 if (toasts[control].count == 1) {
474 message_count_label->hide();
475 } else {
476 message_count_label->set_text(vformat("(%d)", toasts[control].count));
477 message_count_label->show();
478 }
479
480 vbox_container->reset_size();
481
482 is_processing_error = false;
483}
484
485void EditorToaster::close(Control *p_control) {
486 ERR_FAIL_COND(!toasts.has(p_control));
487 toasts[p_control].remaining_time = -1.0;
488 toasts[p_control].popped = false;
489}
490
491void EditorToaster::_close_button_theme_changed(Control *p_close_button) {
492 Button *close_button = Object::cast_to<Button>(p_close_button);
493 if (close_button) {
494 close_button->set_icon(get_editor_theme_icon(SNAME("Close")));
495 }
496}
497
498EditorToaster *EditorToaster::get_singleton() {
499 return singleton;
500}
501
502void EditorToaster::_bind_methods() {
503 // Binding method to make it defer-able.
504 ClassDB::bind_method(D_METHOD("_popup_str", "message", "severity", "tooltip"), &EditorToaster::_popup_str);
505}
506
507EditorToaster::EditorToaster() {
508 set_notify_transform(true);
509 set_process_internal(true);
510
511 // VBox.
512 vbox_container = memnew(VBoxContainer);
513 vbox_container->set_as_top_level(true);
514 vbox_container->connect("resized", callable_mp(this, &EditorToaster::_update_vbox_position));
515 add_child(vbox_container);
516
517 // Theming (background).
518 info_panel_style_background.instantiate();
519 info_panel_style_background->set_corner_radius_all(stylebox_radius * EDSCALE);
520
521 warning_panel_style_background.instantiate();
522 warning_panel_style_background->set_border_width(SIDE_LEFT, stylebox_radius * EDSCALE);
523 warning_panel_style_background->set_corner_radius_all(stylebox_radius * EDSCALE);
524
525 error_panel_style_background.instantiate();
526 error_panel_style_background->set_border_width(SIDE_LEFT, stylebox_radius * EDSCALE);
527 error_panel_style_background->set_corner_radius_all(stylebox_radius * EDSCALE);
528
529 Ref<StyleBoxFlat> boxes[] = { info_panel_style_background, warning_panel_style_background, error_panel_style_background };
530 for (int i = 0; i < 3; i++) {
531 boxes[i]->set_content_margin_individual(int(stylebox_radius * 2.5), 3, int(stylebox_radius * 2.5), 3);
532 }
533
534 // Theming (progress).
535 info_panel_style_progress.instantiate();
536 info_panel_style_progress->set_corner_radius_all(stylebox_radius * EDSCALE);
537
538 warning_panel_style_progress.instantiate();
539 warning_panel_style_progress->set_border_width(SIDE_LEFT, stylebox_radius * EDSCALE);
540 warning_panel_style_progress->set_corner_radius_all(stylebox_radius * EDSCALE);
541
542 error_panel_style_progress.instantiate();
543 error_panel_style_progress->set_border_width(SIDE_LEFT, stylebox_radius * EDSCALE);
544 error_panel_style_progress->set_corner_radius_all(stylebox_radius * EDSCALE);
545
546 // Main button.
547 main_button = memnew(Button);
548 main_button->set_tooltip_text(TTR("No notifications."));
549 main_button->set_modulate(Color(0.5, 0.5, 0.5));
550 main_button->set_disabled(true);
551 main_button->set_flat(true);
552 main_button->connect("pressed", callable_mp(this, &EditorToaster::_set_notifications_enabled).bind(true));
553 main_button->connect("pressed", callable_mp(this, &EditorToaster::_repop_old));
554 main_button->connect("draw", callable_mp(this, &EditorToaster::_draw_button));
555 add_child(main_button);
556
557 // Disable notification button.
558 disable_notifications_panel = memnew(PanelContainer);
559 disable_notifications_panel->set_as_top_level(true);
560 disable_notifications_panel->add_theme_style_override("panel", info_panel_style_background);
561 add_child(disable_notifications_panel);
562
563 disable_notifications_button = memnew(Button);
564 disable_notifications_button->set_tooltip_text(TTR("Silence the notifications."));
565 disable_notifications_button->set_flat(true);
566 disable_notifications_button->connect("pressed", callable_mp(this, &EditorToaster::_set_notifications_enabled).bind(false));
567 disable_notifications_panel->add_child(disable_notifications_button);
568
569 // Other
570 singleton = this;
571
572 eh.errfunc = _error_handler;
573 add_error_handler(&eh);
574};
575
576EditorToaster::~EditorToaster() {
577 singleton = nullptr;
578 remove_error_handler(&eh);
579}
580