From c3f4fbd973d0c4ea5252e0eb046ee72aa95f695b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Harkestad=20B=C3=A6verfjord?= Date: Tue, 8 Feb 2022 22:25:31 +0100 Subject: [PATCH] feat: warn user before losing progress on an unfinished practice (#277) Co-authored-by: Nathan Lovato --- autoload/NavigationManager.gd | 131 ++++++++++++---- project.godot | 10 +- ui/UILesson.gd | 2 +- ui/UINavigatablePage.gd | 52 +++++++ ui/UINavigator.gd | 12 +- ui/UIPractice.gd | 15 +- ui/UIPractice.tscn | 8 +- .../popups/PracticeLeaveUnfinishedPopup.gd | 59 +++++++ .../popups/PracticeLeaveUnfinishedPopup.tscn | 146 ++++++++++++++++++ 9 files changed, 396 insertions(+), 39 deletions(-) create mode 100644 ui/UINavigatablePage.gd create mode 100644 ui/components/popups/PracticeLeaveUnfinishedPopup.gd create mode 100644 ui/components/popups/PracticeLeaveUnfinishedPopup.tscn diff --git a/autoload/NavigationManager.gd b/autoload/NavigationManager.gd index 379bdb1d..40d577a0 100644 --- a/autoload/NavigationManager.gd +++ b/autoload/NavigationManager.gd @@ -1,16 +1,26 @@ extends Node -signal navigation_requested() -signal back_navigation_requested() -signal outliner_navigation_requested() -signal welcome_screen_navigation_requested() +signal navigation_requested +signal back_navigation_requested +signal outliner_navigation_requested +signal welcome_screen_navigation_requested +signal last_screen_unload_requested +signal all_screens_unload_requested + +enum UNLOAD_TYPE { BACK, OUTLINER } + +const ERROR_WRONG_UNLOAD_TYPE := "Unsupported unload type in NavigationManager! Unload type: %s" -var _url_normalization_regex := RegExpGroup.compile("^(?user:\\/\\/|res:\\/\\/|\\.*?\\/+)(?.*)\\.(?t?res)") var history := PoolStringArray() var current_url := "" setget set_current_url, get_current_url var is_mobile_platform := OS.get_name() in ["Android", "HTML5", "iOS"] var arguments := {} +var _current_unload_type := -1 +var _url_normalization_regex := RegExpGroup.compile( + "^(?user:\\/\\/|res:\\/\\/|\\.*?\\/+)(?.*)\\.(?t?res)" +) + func _init() -> void: _parse_arguments() @@ -21,6 +31,7 @@ func _init() -> void: if initial_url != "": navigate_to(initial_url) + func _parse_arguments() -> void: arguments = {} for argument in OS.get_cmdline_args(): @@ -31,13 +42,70 @@ func _parse_arguments() -> void: arguments[key] = value +# Checks if any resource with active user data is about to be closed. +# +# If the current screen is a Practice it might have code edited. If the current +# screen is a Lesson it might be shadowing a Practice. +func _is_unload_confirmation_required() -> bool: + # For the home screen and outliner, get_current_url() returns "". We use + # that to return false for those screens. + if get_current_url(): + var resource = get_navigation_resource(get_current_url()) + return resource is Practice or resource is Lesson + + return false + + func get_history(n := 1) -> String: if n > history.size(): return "" return history[history.size() - n] +# Called by any screen that is to be unloaded (but it is not safe/user denied) +func deny_unload() -> void: + _current_unload_type = -1 + + +# Called by any screen that is to be unloaded +func confirm_unload() -> void: + match _current_unload_type: + UNLOAD_TYPE.BACK: + _navigate_back() + UNLOAD_TYPE.OUTLINER: + _navigate_to_outliner() + _: + printerr(ERROR_WRONG_UNLOAD_TYPE % _current_unload_type) + + _current_unload_type = -1 + + +# Call to navigate back from within the app. If the user is about to lose data, +# they'll get a popup window preventing them from navigating back until they +# confirm they want to leave the screen. +# +# For browser-only navigation, use _navigate_back() instead. func navigate_back() -> void: + if _is_unload_confirmation_required(): + _current_unload_type = UNLOAD_TYPE.BACK + emit_signal("last_screen_unload_requested") + return + + _navigate_back() + + +func navigate_to_outliner() -> void: + if _is_unload_confirmation_required(): + _current_unload_type = UNLOAD_TYPE.OUTLINER + emit_signal("all_screens_unload_requested") + return + + _navigate_to_outliner() + + +# Navigates back instantly, without confirmation popups. Use this for browser +# navigation. +func _navigate_back() -> void: # Nothing to go back to, open the outliner. if history.size() < 2: navigate_to_outliner() @@ -45,15 +113,15 @@ func navigate_back() -> void: history.remove(history.size() - 1) _js_back() - + emit_signal("back_navigation_requested") -func navigate_to_outliner() -> void: +func _navigate_to_outliner() -> void: # prints("emptying history") history.resize(0) _js_to_outliner() - + emit_signal("outliner_navigation_requested") @@ -64,41 +132,41 @@ func navigate_to_welcome_screen() -> void: func navigate_to(metadata: String) -> void: var regex_result := _url_normalization_regex.search(metadata) if not regex_result: - push_error("`%s` is not a valid resource path"%[metadata]) + push_error("`%s` is not a valid resource path" % [metadata]) return var normalized := NormalizedUrl.new(regex_result) - + if not normalized.path: push_error("`%s` is not a valid path" % metadata) return - + var file_path := normalized.get_file_path() - + var resource := get_navigation_resource(file_path) if not (resource is Resource): push_error("`%s` is not a resource" % file_path) return - + history.push_back(file_path) _push_javascript_state(normalized.get_web_url()) emit_signal("navigation_requested") -func get_navigation_resource(resource_id : String) -> Resource: +func get_navigation_resource(resource_id: String) -> Resource: var is_lesson := resource_id.ends_with("lesson.tres") - + if is_lesson: return load(resource_id) as Resource - + var lesson_path := resource_id.get_base_dir().plus_file("lesson.tres") var lesson_data := load(lesson_path) as Lesson - + # If it's not a lesson, it's a practice. May support some other types in future. for practice_res in lesson_data.practices: if practice_res.practice_id == resource_id: return practice_res - + return null @@ -155,10 +223,10 @@ func _on_init_setup_js() -> void: if not _js_available: return _js_history = JavaScript.get_interface("history") - + # if the reference doesn't survive the method call, the callback will be dereferenced _js_popstate_listener_ref = JavaScript.create_callback(self, "_on_js_popstate") - + _js_window = JavaScript.get_interface("window") # warning-ignore:unsafe_method_access _js_window.addEventListener("popstate", _js_popstate_listener_ref) @@ -167,10 +235,12 @@ func _on_init_setup_js() -> void: var url: String = ( # warning-ignore:unsafe_property_access # warning-ignore:unsafe_property_access - _js_window.location.hash.trim_prefix("#").trim_prefix("/") if _js_window.location.hash else "" + _js_window.location.hash.trim_prefix("#").trim_prefix("/") + if _js_window.location.hash + else "" ) if url: - navigate_to("res://%s"%[url]) + navigate_to("res://%s" % [url]) # Handles user changing the url manually or pressing back @@ -178,8 +248,8 @@ func _on_js_popstate(_args: Array) -> void: # we have set this to `false` either in _js_to_outliner or _js_back, we can set it back to true now if _temporary_disable_back_listener: return - # var event = args[0] - navigate_back() + _navigate_back() + # Call this from GDScript to synchronize the browser. Safe to call in all environments, will no-op # when JS is not available. @@ -213,13 +283,15 @@ func _restore_popstate_listener() -> void: yield(get_tree().create_timer(0.3), "timeout") _temporary_disable_back_listener = false + # Call this from GDScript to synchronize the browser. Safe to call in all environments, will no-op # when JS is not available. func _push_javascript_state(url: String) -> void: if not _js_available: return # warning-ignore:unsafe_method_access - _js_history.pushState(url, '', '#'+url) + _js_history.pushState(url, "", "#" + url) + class NormalizedUrl: var protocol := "" @@ -232,13 +304,12 @@ class NormalizedUrl: extension = regex_result.get_string("extension") if protocol in ["//", "/"]: protocol = "res://" - - + func get_file_path() -> String: - return "%s%s.%s"%[protocol, path, extension] - + return "%s%s.%s" % [protocol, path, extension] + func get_web_url() -> String: - return "%s.%s"%[path, extension] + return "%s.%s" % [path, extension] func _to_string() -> String: return protocol + path diff --git a/project.godot b/project.godot index d8c7c793..3eda0c64 100644 --- a/project.godot +++ b/project.godot @@ -239,17 +239,22 @@ _global_script_classes=[ { "language": "GDScript", "path": "res://ui/screens/lesson/UIContentBlock.gd" }, { -"base": "Control", +"base": "UINavigatablePage", "class": "UILesson", "language": "GDScript", "path": "res://ui/UILesson.gd" }, { +"base": "Control", +"class": "UINavigatablePage", +"language": "GDScript", +"path": "res://ui/UINavigatablePage.gd" +}, { "base": "PanelContainer", "class": "UINavigator", "language": "GDScript", "path": "res://ui/UINavigator.gd" }, { -"base": "Control", +"base": "UINavigatablePage", "class": "UIPractice", "language": "GDScript", "path": "res://ui/UIPractice.gd" @@ -327,6 +332,7 @@ _global_script_class_icons={ "UIBaseQuiz": "", "UIContentBlock": "", "UILesson": "", +"UINavigatablePage": "", "UINavigator": "", "UIPractice": "", "UIPracticeButton": "", diff --git a/ui/UILesson.gd b/ui/UILesson.gd index 1cae21f4..50e8f17d 100644 --- a/ui/UILesson.gd +++ b/ui/UILesson.gd @@ -4,7 +4,7 @@ # When pressing a practice button, emits an event so the navigation can # transition to the practice screen. class_name UILesson -extends Control +extends UINavigatablePage const ContentBlockScene := preload("screens/lesson/UIContentBlock.tscn") const QuizInputFieldScene := preload("screens/lesson/quizzes/UIQuizInputField.tscn") diff --git a/ui/UINavigatablePage.gd b/ui/UINavigatablePage.gd new file mode 100644 index 00000000..d2be5b4a --- /dev/null +++ b/ui/UINavigatablePage.gd @@ -0,0 +1,52 @@ +# Base class for lesson and practice pages. Provides an interface for navigation +# in relation with NavigationManager. +# +# Defines required overridable functions for interaction with +# NavigationManager.gd. +# +# Defaults to always accept being unlaoded unless overriden. +class_name UINavigatablePage +extends Control + +var _is_current_screen := false + + +func _ready() -> void: + NavigationManager.connect( + "all_screens_unload_requested", self, "_on_all_screens_unload_requested" + ) + + +func set_is_current_screen(value: bool) -> void: + if _is_current_screen == value: + return + + _is_current_screen = value + + if _is_current_screen: + NavigationManager.connect( + "last_screen_unload_requested", self, "_on_current_screen_unload_requested" + ) + else: + NavigationManager.disconnect( + "last_screen_unload_requested", self, "_on_current_screen_unload_requested" + ) + + +func _accept_unload() -> void: + NavigationManager.confirm_unload() + + +func _deny_unload() -> void: + NavigationManager.deny_unload() + + +# Overridde if unload requires waiting +func _on_current_screen_unload_requested() -> void: + _accept_unload() + + +# Overridde if unload requires waiting +func _on_all_screens_unload_requested() -> void: + if _is_current_screen: + _on_current_screen_unload_requested() diff --git a/ui/UINavigator.gd b/ui/UINavigator.gd index ffc11f24..20fb1eb8 100644 --- a/ui/UINavigator.gd +++ b/ui/UINavigator.gd @@ -99,14 +99,16 @@ func _navigate_back() -> void: _navigate_to_outliner() return - var current_screen: Control = _screens_stack.pop_back() - var next_screen: Control = _screens_stack.back() + var current_screen: UINavigatablePage = _screens_stack.pop_back() + var next_screen: UINavigatablePage = _screens_stack.back() _update_back_button(_screens_stack.size() < 2) # warning-ignore:unsafe_method_access var target = next_screen.get_screen_resource() _breadcrumbs.update_breadcrumbs(course, target) + next_screen.set_is_current_screen(true) + _transition_to(next_screen, current_screen, false) yield(self, "transition_completed") current_screen.queue_free() @@ -135,7 +137,7 @@ func _navigate_to_outliner() -> void: # Navigates forward to the next screen and adds it to the stack. func _navigate_to() -> void: var target := NavigationManager.get_navigation_resource(NavigationManager.current_url) - var screen: Control + var screen: UINavigatablePage if target is Practice: var lesson = course.lessons[_lesson_index] @@ -160,12 +162,14 @@ func _navigate_to() -> void: var has_previous_screen = not _screens_stack.empty() _screens_stack.push_back(screen) + screen.set_is_current_screen(true) _back_button.show() _update_back_button(_screens_stack.size() < 2) _screen_container.add_child(screen) if has_previous_screen: - var previous_screen: Control = _screens_stack[-2] + var previous_screen: UINavigatablePage = _screens_stack[-2] + previous_screen.set_is_current_screen(false) _transition_to(screen, previous_screen) yield(self, "transition_completed") diff --git a/ui/UIPractice.gd b/ui/UIPractice.gd index 63561e65..cf05afc3 100644 --- a/ui/UIPractice.gd +++ b/ui/UIPractice.gd @@ -1,6 +1,6 @@ tool class_name UIPractice -extends Control +extends UINavigatablePage const RUN_AUTOTIMER_DURATION := 5.0 const SLIDE_TRANSITION_DURATION := 0.5 @@ -8,6 +8,7 @@ const SLIDE_TRANSITION_DURATION := 0.5 const PracticeHintScene := preload("screens/practice/PracticeHint.tscn") const PracticeListPopup := preload("components/popups/PracticeListPopup.gd") const PracticeDonePopup := preload("components/popups/PracticeDonePopup.gd") +const PracticeLeaveUnfinishedPopup := preload("components/popups/PracticeLeaveUnfinishedPopup.gd") export var test_practice: Resource @@ -50,6 +51,7 @@ onready var _hints_container := _info_panel.hints_container as Revealer onready var _practice_list := find_node("PracticeListPopup") as PracticeListPopup onready var _practice_done_popup := find_node("PracticeDonePopup") as PracticeDonePopup +onready var _practice_leave_unfinished_popup := find_node("PracticeLeaveUnfinishedPopup") as PracticeLeaveUnfinishedPopup onready var _code_editor := find_node("CodeEditor") as CodeEditor @@ -81,6 +83,9 @@ func _ready() -> void: _practice_done_popup.connect("accepted", self, "_on_next_requested") + _practice_leave_unfinished_popup.connect("confirmed", self, "_accept_unload") + _practice_leave_unfinished_popup.connect("denied", self, "_deny_unload") + Events.connect("practice_run_completed", self, "_test_student_code") _update_slidable_panels() @@ -533,6 +538,14 @@ func _on_autotimer_timeout() -> void: Events.emit_signal("practice_run_completed") +func _on_current_screen_unload_requested() -> void: + if not _code_editor_is_dirty: + _accept_unload() + return + + _practice_leave_unfinished_popup.popup() + + # Updates all nodes with the given script. If a node path isn't valid, the node # will be silently skipped. func _update_nodes(script: GDScript, node_paths: Array) -> void: diff --git a/ui/UIPractice.tscn b/ui/UIPractice.tscn index 853547ad..086a0397 100644 --- a/ui/UIPractice.tscn +++ b/ui/UIPractice.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=16 format=2] +[gd_scene load_steps=17 format=2] [ext_resource path="res://ui/components/CodeEditor.tscn" type="PackedScene" id=1] [ext_resource path="res://ui/theme/panel_course_page.tres" type="StyleBox" id=2] @@ -15,6 +15,7 @@ [ext_resource path="res://ui/theme/fonts/font_title_small.tres" type="DynamicFont" id=13] [ext_resource path="res://ui/theme/panel_sliceeditor_title.tres" type="StyleBox" id=14] [ext_resource path="res://ui/theme/gdscript_app_theme.tres" type="Theme" id=15] +[ext_resource path="res://ui/components/popups/PracticeLeaveUnfinishedPopup.tscn" type="PackedScene" id=16] [node name="UIPractice" type="PanelContainer"] pause_mode = 2 @@ -191,4 +192,9 @@ anchor_bottom = 0.0 margin_right = 1920.0 margin_bottom = 1080.0 +[node name="PracticeLeaveUnfinishedPopup" parent="." instance=ExtResource( 16 )] +visible = false +title = "Leave unfinished practice?" +text_content = "You will lose your progress on this practice!" + [node name="Tween" type="Tween" parent="."] diff --git a/ui/components/popups/PracticeLeaveUnfinishedPopup.gd b/ui/components/popups/PracticeLeaveUnfinishedPopup.gd new file mode 100644 index 00000000..f6e5281b --- /dev/null +++ b/ui/components/popups/PracticeLeaveUnfinishedPopup.gd @@ -0,0 +1,59 @@ +tool +extends ColorRect + +signal confirmed +signal denied + +export var title := "" setget set_title +export(String, MULTILINE) var text_content := "" setget set_text_content +export var min_size := Vector2(200, 120) setget set_min_size + +onready var _root_container := $PanelContainer as Container +onready var _top_bar := $PanelContainer/Column/ProgressBar as ProgressBar +onready var _title_label := $PanelContainer/Column/Margin/Column/Title as Label +onready var _message_content := $PanelContainer/Column/Margin/Column/Message as RichTextLabel + +onready var _confirm_button := $PanelContainer/Column/Margin/Column/Buttons/ConfirmButton as Button +onready var _cancel_button := $PanelContainer/Column/Margin/Column/Buttons/CancelButton as Button + + +func _ready(): + set_as_toplevel(true) + _root_container.rect_min_size = min_size + _root_container.rect_size = _root_container.rect_min_size + _root_container.set_anchors_and_margins_preset(Control.PRESET_CENTER) + + _title_label.text = title + _message_content.bbcode_text = text_content + + _confirm_button.connect("pressed", self, "emit_signal", ["confirmed"]) + _confirm_button.connect("pressed", self, "hide") + + _cancel_button.connect("pressed", self, "emit_signal", ["denied"]) + _cancel_button.connect("pressed", self, "hide") + + +func set_title(value: String) -> void: + title = value + if is_inside_tree(): + _title_label.text = title + + +func set_text_content(value: String) -> void: + text_content = value + if is_inside_tree(): + _message_content.bbcode_text = text_content + + +func set_min_size(value: Vector2) -> void: + min_size = value + if is_inside_tree(): + _root_container.rect_min_size = min_size + _root_container.rect_size = _root_container.rect_min_size + _root_container.set_anchors_and_margins_preset(Control.PRESET_CENTER, Control.PRESET_MODE_KEEP_SIZE) + + +func popup() -> void: + show() + _root_container.rect_size = _root_container.rect_min_size + _root_container.set_anchors_and_margins_preset(Control.PRESET_CENTER, Control.PRESET_MODE_KEEP_SIZE) diff --git a/ui/components/popups/PracticeLeaveUnfinishedPopup.tscn b/ui/components/popups/PracticeLeaveUnfinishedPopup.tscn new file mode 100644 index 00000000..ffa0f05e --- /dev/null +++ b/ui/components/popups/PracticeLeaveUnfinishedPopup.tscn @@ -0,0 +1,146 @@ +[gd_scene load_steps=10 format=2] + +[ext_resource path="res://ui/components/popups/PracticeLeaveUnfinishedPopup.gd" type="Script" id=1] +[ext_resource path="res://ui/theme/fonts/font_title.tres" type="DynamicFont" id=2] +[ext_resource path="res://ui/theme/button_outline_large_pressed.tres" type="StyleBox" id=3] +[ext_resource path="res://ui/theme/button_outline_large_normal.tres" type="StyleBox" id=4] +[ext_resource path="res://ui/theme/panel_normal.tres" type="StyleBox" id=5] +[ext_resource path="res://ui/theme/button_outline_large_hover.tres" type="StyleBox" id=6] +[ext_resource path="res://ui/theme/gdscript_app_theme.tres" type="Theme" id=7] + +[sub_resource type="StyleBoxFlat" id=1] +bg_color = Color( 1, 0.0941176, 0.321569, 1 ) +border_color = Color( 0.572549, 0.560784, 0.721569, 1 ) +corner_radius_top_left = 8 +corner_radius_top_right = 8 +corner_radius_bottom_right = 8 +corner_radius_bottom_left = 8 + +[sub_resource type="StyleBoxFlat" id=2] +content_margin_left = 8.0 +content_margin_right = 8.0 +content_margin_top = 8.0 +content_margin_bottom = 8.0 +bg_color = Color( 1, 0.0941176, 0.321569, 1 ) +border_color = Color( 0.572549, 0.560784, 0.721569, 1 ) +corner_radius_top_left = 8 +corner_radius_top_right = 8 +corner_radius_bottom_right = 8 +corner_radius_bottom_left = 8 + +[node name="PracticeLeaveUnfinishedPopup" type="ColorRect"] +anchor_right = 1.0 +anchor_bottom = 1.0 +color = Color( 0.0352941, 0.0392157, 0.129412, 0.627451 ) +script = ExtResource( 1 ) +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="PanelContainer" type="PanelContainer" parent="."] +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +margin_left = -228.0 +margin_top = -115.5 +margin_right = 228.0 +margin_bottom = 115.5 +theme = ExtResource( 7 ) +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="Panel" type="Panel" parent="PanelContainer"] +margin_right = 456.0 +margin_bottom = 260.0 +custom_styles/panel = ExtResource( 5 ) + +[node name="Column" type="VBoxContainer" parent="PanelContainer"] +margin_right = 456.0 +margin_bottom = 260.0 +custom_constants/separation = 0 + +[node name="ProgressBar" type="ProgressBar" parent="PanelContainer/Column"] +margin_right = 456.0 +margin_bottom = 16.0 +rect_min_size = Vector2( 0, 16 ) +custom_styles/fg = SubResource( 1 ) +value = 100.0 +percent_visible = false + +[node name="Margin" type="MarginContainer" parent="PanelContainer/Column"] +margin_top = 16.0 +margin_right = 456.0 +margin_bottom = 260.0 +size_flags_vertical = 3 + +[node name="Column" type="VBoxContainer" parent="PanelContainer/Column/Margin"] +margin_left = 20.0 +margin_top = 20.0 +margin_right = 436.0 +margin_bottom = 224.0 +custom_constants/separation = 12 + +[node name="Title" type="Label" parent="PanelContainer/Column/Margin/Column"] +margin_right = 416.0 +margin_bottom = 31.0 +custom_fonts/font = ExtResource( 2 ) +align = 1 + +[node name="Separator" type="HSeparator" parent="PanelContainer/Column/Margin/Column"] +margin_left = 8.0 +margin_top = 43.0 +margin_right = 408.0 +margin_bottom = 51.0 +rect_min_size = Vector2( 400, 0 ) +size_flags_horizontal = 4 + +[node name="Message" type="RichTextLabel" parent="PanelContainer/Column/Margin/Column"] +margin_top = 63.0 +margin_right = 416.0 +margin_bottom = 92.0 +size_flags_vertical = 3 +bbcode_enabled = true +fit_content_height = true + +[node name="Spacer" type="Control" parent="PanelContainer/Column/Margin/Column"] +margin_top = 104.0 +margin_right = 416.0 +margin_bottom = 124.0 +rect_min_size = Vector2( 400, 20 ) + +[node name="Buttons" type="HBoxContainer" parent="PanelContainer/Column/Margin/Column"] +margin_top = 136.0 +margin_right = 416.0 +margin_bottom = 204.0 +alignment = 1 + +[node name="ConfirmButton" type="Button" parent="PanelContainer/Column/Margin/Column/Buttons"] +margin_right = 200.0 +margin_bottom = 68.0 +rect_min_size = Vector2( 200, 68 ) +mouse_default_cursor_shape = 2 +size_flags_horizontal = 4 +custom_colors/font_color = Color( 0.188235, 0.188235, 0.286275, 1 ) +custom_colors/font_color_hover = Color( 0.74902, 0.741176, 0.85098, 1 ) +custom_colors/font_color_pressed = Color( 0.290196, 0.294118, 0.388235, 1 ) +custom_styles/hover = ExtResource( 6 ) +custom_styles/pressed = ExtResource( 3 ) +custom_styles/normal = SubResource( 2 ) +text = "Confirm" + +[node name="CancelButton" type="Button" parent="PanelContainer/Column/Margin/Column/Buttons"] +margin_left = 216.0 +margin_right = 416.0 +margin_bottom = 68.0 +rect_min_size = Vector2( 200, 68 ) +mouse_default_cursor_shape = 2 +size_flags_horizontal = 4 +custom_colors/font_color = Color( 0.572549, 0.560784, 0.721569, 1 ) +custom_colors/font_color_hover = Color( 0.74902, 0.741176, 0.85098, 1 ) +custom_colors/font_color_pressed = Color( 0.290196, 0.294118, 0.388235, 1 ) +custom_styles/hover = ExtResource( 6 ) +custom_styles/pressed = ExtResource( 3 ) +custom_styles/normal = ExtResource( 4 ) +text = "Cancel"