diff --git a/core/global/install_manager.gd b/core/global/install_manager.gd index 43dcc2de..af1781a4 100644 --- a/core/global/install_manager.gd +++ b/core/global/install_manager.gd @@ -89,12 +89,16 @@ func _process_install_queue() -> void: # Install the given application using the given provider install_started.emit(req) var result: Array - if req._type == REQUEST_TYPE.INSTALL: - req.provider.install(req.item) - result = await req.provider.install_completed - else: - req.provider.update(req.item) - result = await req.provider.update_completed + match req._type: + REQUEST_TYPE.INSTALL: + req.provider.install_to(req.item, req.location, req.options) + result = await req.provider.install_completed + REQUEST_TYPE.UPDATE: + req.provider.update(req.item) + result = await req.provider.update_completed + _: + logger.warn("Unknown request type:", req._type) + result = [req.item, false] req.success = result[1] logger.info("Install of '" + req.item.name + "' completed with success: " + str(req.success)) @@ -116,10 +120,14 @@ class Request extends RefCounted: signal completed(success: bool) var provider: Library var item: LibraryLaunchItem + var location: Library.InstallLocation + var options: Dictionary var progress: float var success: bool var _type: REQUEST_TYPE - func _init(library_provider: Library, launch_item: LibraryLaunchItem) -> void: + func _init(library_provider: Library, launch_item: LibraryLaunchItem, to_location: Library.InstallLocation = null, opts: Dictionary = {}) -> void: provider = library_provider item = launch_item + location = to_location + options = opts diff --git a/core/systems/library/library.gd b/core/systems/library/library.gd index 2e8c9c02..a7699eb8 100644 --- a/core/systems/library/library.gd +++ b/core/systems/library/library.gd @@ -23,8 +23,13 @@ signal install_progressed(item: LibraryLaunchItem, percent_completed: float) signal launch_item_added(item: LibraryLaunchItem) ## Should be emitted when a library item was removed from the library signal launch_item_removed(item: LibraryLaunchItem) +## Should be emitted when a library item is moved to a different install location +signal move_completed(item: LibraryLaunchItem, success: bool) +## Should be emitted when a library item move is progressing +signal move_progressed(item: LibraryLaunchItem, percent_completed: float) -var LibraryManager := load("res://core/global/library_manager.tres") as LibraryManager +var LibraryManager := load("res://core/global/library_manager.tres") as LibraryManager # TODO: Remove this +var library_manager := load("res://core/global/library_manager.tres") as LibraryManager ## Unique identifier for the library @export var library_id: String @@ -45,7 +50,7 @@ var LibraryManager := load("res://core/global/library_manager.tres") as LibraryM func _init() -> void: add_to_group("library") - ready.connect(LibraryManager.register_library.bind(self)) + ready.connect(library_manager.register_library.bind(self)) # Called when the node enters the scene tree for the first time. @@ -71,36 +76,99 @@ func get_library_launch_items() -> Array[LibraryLaunchItem]: return [] +## Returns an array of available install locations for this library provider. +## This method should be overridden in the child class. +## Example: +## [codeblock] +## func get_available_install_locations() -> Array[InstallLocation]: +## var location := InstallLocation.new() +## location.name = "/" +## return [location] +## [/codeblock] +func get_available_install_locations(item: LibraryLaunchItem = null) -> Array[InstallLocation]: + return [] + + +## Returns an array of install options for the given [LibraryLaunchItem]. +## Install options are arbitrary and are provider-specific. They allow the user +## to select things like the language of a game to install, etc. +## Example: +## [codeblock] +## func get_install_options(item: LibraryLaunchItem) -> Array[InstallOption]: +## var option := InstallOption.new() +## option.id = "lang" +## option.name = "Language" +## option.description = "Language of the game to install" +## option.values = ["english", "spanish"] +## option.value_type = TYPE_STRING +## return [option] +## [/codeblock] +func get_install_options(item: LibraryLaunchItem) -> Array[InstallOption]: + return [] + + +## This method should be overridden if the library requires executing callbacks +## at certain points in an app's lifecycle, such as when an app is starting or +## stopping. +func get_app_lifecycle_hooks() -> Array[AppLifecycleHook]: + var hooks: Array[AppLifecycleHook] + return hooks + + +## [DEPRECATED] ## Installs the given library item. This method should be overriden in the ## child class, if it supports it. func install(item: LibraryLaunchItem) -> void: pass +## Installs the given library item to the given location. This method should be +## overridden in the child class, if it supports it. +func install_to(item: LibraryLaunchItem, location: InstallLocation = null, options: Dictionary = {}) -> void: + install(item) + + ## Updates the given library item. This method should be overriden in the ## child class, if it supports it. func update(item: LibraryLaunchItem) -> void: pass +## Should return true if the given library item has an update available +func has_update(item: LibraryLaunchItem) -> bool: + return false + + ## Uninstalls the given library item. This method should be overriden in the ## child class if it supports it. func uninstall(item: LibraryLaunchItem) -> void: pass -## Should return true if the given library item has an update available -func has_update(item: LibraryLaunchItem) -> bool: - return false - - -## This method should be overridden if the library requires executing callbacks -## at certain points in an app's lifecycle, such as when an app is starting or -## stopping. -func get_app_lifecycle_hooks() -> Array[AppLifecycleHook]: - var hooks: Array[AppLifecycleHook] - return hooks +## Move the given library item to the given install location. This method should +## be overriden in the child class if it supports it. +func move(item: LibraryLaunchItem, to_location: InstallLocation) -> void: + pass func _exit_tree() -> void: - LibraryManager.unregister_library(self) + library_manager.unregister_library(self) + + +## InstallLocation defines a place where a [LibraryLaunchItem] can be installed. +class InstallLocation extends RefCounted: + var id: String + var name: String + var description: String + var icon: Texture2D + var total_space_mb: int + var free_space_mb: int + + +## InstallOption defines an arbitrary install option for a [LibraryLaunchitem]. +class InstallOption extends RefCounted: + var id: String + var name: String + var description: String + var values: Array[Variant] + var value_type: int diff --git a/core/ui/card_ui/launch/game_launch_menu.gd b/core/ui/card_ui/launch/game_launch_menu.gd index 38475b00..f6f476a1 100644 --- a/core/ui/card_ui/launch/game_launch_menu.gd +++ b/core/ui/card_ui/launch/game_launch_menu.gd @@ -18,15 +18,17 @@ var logger := Log.get_logger("GameLaunchMenu") @export var launch_item: LibraryLaunchItem -@onready var banner: TextureRect = $%BannerTexture -@onready var logo: TextureRect = $%LogoTexture -@onready var launch_button := $%LaunchButton -@onready var loading: Control = $%LoadingAnimation -@onready var player := $%AnimationPlayer -@onready var progress_container := $%ProgressContainer -@onready var progress_bar: ProgressBar = $%ProgressBar -@onready var delete_container := $%DeleteContainer -@onready var delete_button := $%DeleteButton +@onready var banner := $%BannerTexture as TextureRect +@onready var logo := $%LogoTexture as TextureRect +@onready var launch_button := $%LaunchButton as CardButton +@onready var loading := $%LoadingAnimation as Control +@onready var player := $%AnimationPlayer as AnimationPlayer +@onready var progress_container := $%ProgressContainer as MarginContainer +@onready var progress_bar := $%ProgressBar as ProgressBar +@onready var delete_container := $%DeleteContainer as MarginContainer +@onready var delete_button := $%DeleteButton as CardIconButton +@onready var location_menu := $%InstallLocationDialog as InstallLocationDialog +@onready var options_menu := $%InstallOptionsDialog as InstallOptionDialog # Called when the node enters the scene tree for the first time. @@ -55,7 +57,11 @@ func _process(_delta: float) -> void: func _on_state_entered(_from: State) -> void: # Fade in the banner texture player.play("fade_in") - + + # Ensure dialogs are hidden + location_menu.visible = false + options_menu.visible = false + # Focus the first entry on state change launch_button.grab_focus.call_deferred() @@ -96,11 +102,11 @@ func _on_state_entered(_from: State) -> void: # Load the banner for the game var logo_texture = await ( - BoxArtManager . get_boxart_or_placeholder(library_item, BoxArtProvider.LAYOUT.LOGO) + BoxArtManager.get_boxart_or_placeholder(library_item, BoxArtProvider.LAYOUT.LOGO) ) logo.texture = logo_texture var banner_texture = await ( - BoxArtManager . get_boxart_or_placeholder(library_item, BoxArtProvider.LAYOUT.BANNER) + BoxArtManager.get_boxart_or_placeholder(library_item, BoxArtProvider.LAYOUT.BANNER) ) banner.texture = banner_texture @@ -119,15 +125,15 @@ func _update_launch_button() -> void: if not launch_item: return if launch_item.installed: - launch_button.text = "Play Now" + launch_button.text = tr("Play Now") else: - launch_button.text = "Install" + launch_button.text = tr("Install") if LaunchManager.is_running(launch_item.name): - launch_button.text = "Resume" + launch_button.text = tr("Resume") if InstallManager.is_queued(launch_item): - launch_button.text = "Queued" + launch_button.text = tr("Queued") if InstallManager.is_installing(launch_item): - launch_button.text = "Installing" + launch_button.text = tr("Installing") func _update_uninstall_button() -> void: @@ -162,16 +168,50 @@ func _on_install() -> void: # Do nothing if we're already installing if InstallManager.is_queued_or_installing(launch_item): return - var notify := Notification.new("Installing " + launch_item.name) - NotificationManager.show(notify) - # Create an install request + # Get the library provider for this launch item var provider := LibraryManager.get_library_by_id(launch_item._provider_id) - var install_req := InstallManager.Request.new(provider, launch_item) + + # If multiple install locations are available, ask the user where to + # install. + var location: Library.InstallLocation = null + var locations := await provider.get_available_install_locations(launch_item) + if locations.size() > 0: + # Open the install location menu and wait for the user to select an + # install location. + location_menu.open(launch_button, locations) + var result := await location_menu.choice_selected as Array + var accepted := result[0] as bool + var choice := result[1] as Library.InstallLocation + if not accepted: + return + location = choice + + # If install options are available for this library item, ask the user + # to select from the options. + var options := {} + var available_options := await provider.get_install_options(launch_item) + if available_options.size() > 0: + # Open the install option menu and wait for the user to select install + # options. + options_menu.open(launch_button, available_options) + var result := await options_menu.choice_selected as Array + var accepted := result[0] as bool + var choices := result[1] as Dictionary + if not accepted: + return + options = choices + + # Create an install request + var install_req := InstallManager.Request.new(provider, launch_item, location, options) # Update the progress bar with install progress of the request progress_bar.value = 0 + # Display a notification + var notify := Notification.new("Installing " + launch_item.name) + NotificationManager.show(notify) + # Start the install InstallManager.install(install_req) diff --git a/core/ui/card_ui/launch/game_launch_menu.tscn b/core/ui/card_ui/launch/game_launch_menu.tscn index 1f201075..68b5b2d8 100644 --- a/core/ui/card_ui/launch/game_launch_menu.tscn +++ b/core/ui/card_ui/launch/game_launch_menu.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=22 format=3 uid="uid://bcdk1lj6enq3l"] +[gd_scene load_steps=24 format=3 uid="uid://bcdk1lj6enq3l"] [ext_resource type="Script" path="res://core/ui/card_ui/launch/game_launch_menu.gd" id="1_u3ehs"] [ext_resource type="Texture2D" uid="uid://d1mksukdkqorr" path="res://assets/images/placeholder-grid-banner.png" id="2_oae7b"] @@ -15,7 +15,9 @@ [ext_resource type="PackedScene" uid="uid://cr83fmlociwko" path="res://core/ui/components/card_icon_button.tscn" id="15_f3ktw"] [ext_resource type="PackedScene" uid="uid://b76dvfuouhlwd" path="res://core/systems/state/state_updater.tscn" id="15_lat8h"] [ext_resource type="Resource" uid="uid://cx8u1y5j7vyss" path="res://assets/state/states/gamepad_settings.tres" id="17_7ydn0"] +[ext_resource type="PackedScene" uid="uid://b4u8djfdc4kea" path="res://core/ui/components/install_location_dialog.tscn" id="18_j25yi"] [ext_resource type="Resource" uid="uid://3vw3bk76d88w" path="res://assets/state/states/game_settings.tres" id="19_b21vy"] +[ext_resource type="PackedScene" uid="uid://18axsy5my1x6" path="res://core/ui/components/install_options_dialog.tscn" id="19_k020t"] [ext_resource type="Texture2D" uid="uid://dj1ohb74chydb" path="res://assets/ui/icons/round-delete-forever.svg" id="21_agq5k"] [sub_resource type="Animation" id="Animation_ou6f5"] @@ -252,3 +254,13 @@ size_flags_vertical = 4 theme_override_styles/fill = SubResource("StyleBoxFlat_7fb8y") value = 50.0 rounded = true + +[node name="InstallLocationDialog" parent="." instance=ExtResource("18_j25yi")] +unique_name_in_owner = true +visible = false +layout_mode = 1 + +[node name="InstallOptionsDialog" parent="." instance=ExtResource("19_k020t")] +unique_name_in_owner = true +visible = false +layout_mode = 1 diff --git a/core/ui/components/dropdown.tscn b/core/ui/components/dropdown.tscn index 7bc81770..31b1cc98 100644 --- a/core/ui/components/dropdown.tscn +++ b/core/ui/components/dropdown.tscn @@ -33,7 +33,6 @@ autowrap_mode = 3 [node name="OptionButton" type="OptionButton" parent="."] unique_name_in_owner = true layout_mode = 2 -item_count = 1 fit_to_longest_item = false +item_count = 1 popup/item_0/text = "None" -popup/item_0/id = 0 diff --git a/core/ui/components/install_location_card.gd b/core/ui/components/install_location_card.gd new file mode 100644 index 00000000..9c78a632 --- /dev/null +++ b/core/ui/components/install_location_card.gd @@ -0,0 +1,73 @@ +extends Control +class_name InstallLocationCard + +signal pressed +signal button_up +signal button_down + +@onready var icon := $%IconTextureRect as TextureRect +@onready var name_label := $%DriveName as Label +@onready var desc_label := $%Description as Label +@onready var drive_size_label := $%DriveSize as Label +@onready var drive_used_bar := $%SpaceUsedProgressBar as ProgressBar +@onready var highlight := $%HighlightTexture as TextureRect + +var location: Library.InstallLocation + + +## Creates an [InstallLocationCard] instance for the given install location +static func from_location(location: Library.InstallLocation) -> InstallLocationCard: + var card_scene := load("res://core/ui/components/install_location_card.tscn") as PackedScene + var card := card_scene.instantiate() as InstallLocationCard + card.location = location + + return card + + +# Called when the node enters the scene tree for the first time. +func _ready() -> void: + if not location: + return + if location.icon: + icon.texture = location.icon + + name_label.text = location.name + + desc_label.visible = not location.description.is_empty() + desc_label.text = location.description + + drive_size_label.visible = location.total_space_mb != 0 + drive_size_label.text = str(location.total_space_mb) + " Mb" + + drive_used_bar.visible = location.total_space_mb != 0 + var percent_free := (float(location.free_space_mb) / float(location.total_space_mb)) * 100.0 + drive_used_bar.value = 100.0 - percent_free + + # Find the parent theme and update if required + var effective_theme := ThemeUtils.get_effective_theme(self) + if effective_theme: + _on_theme_changed() + + +func _on_theme_changed() -> void: + # Configure the highlight texture from the theme + var highlight_texture := get_theme_icon("highlight", "CardButton") + if highlight_texture: + highlight.texture = highlight_texture + + +func _gui_input(event: InputEvent) -> void: + var dbus_path := event.get_meta("dbus_path", "") as String + if event is InputEventMouseButton: + if event.is_pressed(): + button_down.emit() + pressed.emit() + else: + button_up.emit() + if not event.is_action("ui_accept"): + return + if event.is_pressed(): + button_down.emit() + pressed.emit() + else: + button_up.emit() diff --git a/core/ui/components/install_location_card.tscn b/core/ui/components/install_location_card.tscn new file mode 100644 index 00000000..fca9f9db --- /dev/null +++ b/core/ui/components/install_location_card.tscn @@ -0,0 +1,110 @@ +[gd_scene load_steps=7 format=3 uid="uid://cuxskwtc3lqnu"] + +[ext_resource type="Script" path="res://core/ui/components/install_location_card.gd" id="1_x8y8g"] +[ext_resource type="PackedScene" uid="uid://c5sfkhrfbao71" path="res://core/systems/effects/play_audio_effect.tscn" id="2_0k85l"] +[ext_resource type="PackedScene" uid="uid://bw8113ocotx2r" path="res://core/systems/effects/fade_effect.tscn" id="3_6v8pv"] +[ext_resource type="Texture2D" uid="uid://bidhj1jikg827" path="res://assets/icons/interface-hdd.svg" id="7_xfusj"] + +[sub_resource type="Gradient" id="Gradient_2e33l"] +colors = PackedColorArray(0.741176, 0.576471, 0.976471, 1, 1, 0.47451, 0.776471, 1) + +[sub_resource type="GradientTexture2D" id="GradientTexture2D_s4kc6"] +gradient = SubResource("Gradient_2e33l") +fill = 1 +fill_to = Vector2(1, 2) + +[node name="InstallLocationCard" type="MarginContainer"] +offset_right = 272.0 +offset_bottom = 229.0 +focus_mode = 2 +script = ExtResource("1_x8y8g") + +[node name="PlayFocusAudioEffect" parent="." instance=ExtResource("2_0k85l")] +on_signal = "focus_entered" + +[node name="PlaySelectedAudioEffect" parent="." instance=ExtResource("2_0k85l")] +audio = "res://assets/audio/interface/select_002.ogg" +on_signal = "pressed" + +[node name="HighlightFadeEffect" parent="." node_paths=PackedStringArray("target") instance=ExtResource("3_6v8pv")] +target = NodePath("../PanelContainer/HighlightTexture") +on_signal = "focus_entered" +fade_out_signal = "focus_exited" +on_signal = "focus_entered" + +[node name="PanelContainer" type="PanelContainer" parent="."] +clip_children = 2 +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +theme_type_variation = &"PluginStoreCard" + +[node name="HighlightTexture" type="TextureRect" parent="PanelContainer"] +unique_name_in_owner = true +visible = false +layout_mode = 2 +texture = SubResource("GradientTexture2D_s4kc6") +expand_mode = 1 +stretch_mode = 6 + +[node name="InsidePanelMargin" type="MarginContainer" parent="."] +layout_mode = 2 +theme_override_constants/margin_left = 4 +theme_override_constants/margin_top = 4 +theme_override_constants/margin_right = 4 +theme_override_constants/margin_bottom = 4 + +[node name="InsidePanel" type="Panel" parent="InsidePanelMargin"] +unique_name_in_owner = true +layout_mode = 2 +theme_type_variation = &"PluginStoreCard" + +[node name="EdgeMarginContainer" type="MarginContainer" parent="."] +layout_mode = 2 +theme_override_constants/margin_left = 20 +theme_override_constants/margin_top = 20 +theme_override_constants/margin_right = 20 +theme_override_constants/margin_bottom = 20 + +[node name="LayoutHBox" type="HBoxContainer" parent="EdgeMarginContainer"] +layout_mode = 2 +theme_override_constants/separation = 20 + +[node name="IconTextureRect" type="TextureRect" parent="EdgeMarginContainer/LayoutHBox"] +unique_name_in_owner = true +custom_minimum_size = Vector2(40, 40) +layout_mode = 2 +texture = ExtResource("7_xfusj") +expand_mode = 1 +stretch_mode = 5 + +[node name="DriveDataVBox" type="VBoxContainer" parent="EdgeMarginContainer/LayoutHBox"] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 4 +theme_override_constants/separation = 20 + +[node name="DriveName" type="Label" parent="EdgeMarginContainer/LayoutHBox/DriveDataVBox"] +unique_name_in_owner = true +layout_mode = 2 +text = "Lipsum Industries SSD +" +horizontal_alignment = 2 + +[node name="Description" type="Label" parent="EdgeMarginContainer/LayoutHBox/DriveDataVBox"] +unique_name_in_owner = true +custom_minimum_size = Vector2(160, 0) +layout_mode = 2 +text = "Description of the drive thing here" +horizontal_alignment = 2 +autowrap_mode = 3 + +[node name="DriveSize" type="Label" parent="EdgeMarginContainer/LayoutHBox/DriveDataVBox"] +unique_name_in_owner = true +layout_mode = 2 +text = "1337 Gb" +horizontal_alignment = 2 + +[node name="SpaceUsedProgressBar" type="ProgressBar" parent="EdgeMarginContainer/LayoutHBox/DriveDataVBox"] +unique_name_in_owner = true +layout_mode = 2 diff --git a/core/ui/components/install_location_dialog.gd b/core/ui/components/install_location_dialog.gd new file mode 100644 index 00000000..231d9de8 --- /dev/null +++ b/core/ui/components/install_location_dialog.gd @@ -0,0 +1,121 @@ +@icon("res://assets/editor-icons/solar--dialog-2-bold.svg") +@tool +extends Control +class_name InstallLocationDialog + +## Emitted when the dialog window is opened +signal opened +## Emitted when the dialog window is closed +signal closed +## Emitted when the user selects an option +signal choice_selected(accepted: bool, location: Library.InstallLocation) + +## Text to display in the dialog box +@export var text: String: + set(v): + text = v + if label: + label.text = v +## Cancel button text +@export var cancel_text: String = "Cancel": + set(v): + cancel_text = v + if cancel_button: + cancel_button.text = v +## Whether or not the cancel button should be shown +@export var cancel_visible: bool = true: + set(v): + cancel_visible = v + if cancel_button: + cancel_button.visible = v +## Close the dialog when the user selects an option +@export var close_on_selected := true +## Maximum size that the scroll container can grow to +@export var custom_maximum_size: Vector2i: + set(v): + custom_maximum_size = v + if scroll_container: + _recalculate_minimum_size() + +@onready var scroll_container := $%ScrollContainer as ScrollContainer +@onready var content_container := $%ContentContainer as Container +@onready var label := $%Label as Label +@onready var cancel_button := $%CancelButton as CardButton +@onready var fade_effect := $%FadeEffect as Effect + +var _return_node: Control = null + +# Called when the node enters the scene tree for the first time. +func _ready() -> void: + cancel_button.button_up.connect(_on_selected.bind(false, null)) + scroll_container.sort_children.connect(_recalculate_minimum_size) + + +## Invoked when an item is selected +func _on_selected(accepted: bool, choice: Library.InstallLocation) -> void: + if close_on_selected: + closed.emit() + choice_selected.emit(accepted, null) + if _return_node: + _return_node.grab_focus.call_deferred() + _return_node = null + + +## Updates the minimum size of the scroll container up to the custom_maximum_size. +## This will allow the scroll container to dynamically grow based on the content +## inside the scroll container up to a maximum size. +func _recalculate_minimum_size() -> void: + if custom_maximum_size == Vector2i.ZERO: + return + if scroll_container.get_child_count() < 1: + return + + # Scroll containers only support having one child node + var child := scroll_container.get_child(0) + if not child is Control: + return + var child_size := (child as Control).size + + # Check the size of the child to see if the max size has been reached. If not, + # adjust the size of the scroll container based on the content. + if custom_maximum_size.x != 0.0 and child_size.x > custom_maximum_size.x: + scroll_container.custom_minimum_size.x = custom_maximum_size.x + else: + scroll_container.custom_minimum_size.x = child_size.x + + if custom_maximum_size.y != 0.0 and child_size.y > custom_maximum_size.y: + scroll_container.custom_minimum_size.y = custom_maximum_size.y + else: + scroll_container.custom_minimum_size.y = child_size.y + + +## Opens the dialog box with the given settings +func open(return_node: Control, locations: Array[Library.InstallLocation] = [], message: String = "", cancel_txt: String = "") -> void: + if message != "": + text = message + if cancel_txt != "": + cancel_text = cancel_txt + _return_node = return_node + + # Clear any old location cards + for child in content_container.get_children(): + if not child is InstallLocationCard: + continue + content_container.remove_child(child) + child.queue_free() + + # Create an install location card for each location + var focus_node: Control = cancel_button + for location in locations: + var location_card := InstallLocationCard.from_location(location) + if not location_card: + continue + content_container.add_child(location_card) + content_container.move_child(location_card, -2) + if focus_node == cancel_button: + focus_node = location_card + location_card.button_up.connect(_on_selected.bind(true, location)) + + opened.emit() + await fade_effect.effect_finished + focus_node.grab_focus.call_deferred() diff --git a/core/ui/components/install_location_dialog.tscn b/core/ui/components/install_location_dialog.tscn new file mode 100644 index 00000000..0317589d --- /dev/null +++ b/core/ui/components/install_location_dialog.tscn @@ -0,0 +1,107 @@ +[gd_scene load_steps=7 format=3 uid="uid://b4u8djfdc4kea"] + +[ext_resource type="Script" path="res://core/ui/components/install_location_dialog.gd" id="1_op1tk"] +[ext_resource type="PackedScene" uid="uid://bw8113ocotx2r" path="res://core/systems/effects/fade_effect.tscn" id="2_gwl35"] +[ext_resource type="PackedScene" uid="uid://ekhjpmat02f8" path="res://core/systems/effects/slide_effect.tscn" id="3_4s56l"] +[ext_resource type="PackedScene" uid="uid://cuxskwtc3lqnu" path="res://core/ui/components/install_location_card.tscn" id="4_8utsh"] +[ext_resource type="PackedScene" uid="uid://8m20p2s0v5gb" path="res://core/systems/input/focus_group.tscn" id="4_aao6t"] +[ext_resource type="PackedScene" uid="uid://c71ayw7pcw6u6" path="res://core/ui/components/card_button.tscn" id="4_krh2n"] + +[node name="InstallLocationDialog" type="Control"] +layout_mode = 3 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_op1tk") +custom_maximum_size = Vector2i(0, 600) + +[node name="FadeEffect" parent="." node_paths=PackedStringArray("target") instance=ExtResource("2_gwl35")] +unique_name_in_owner = true +target = NodePath("..") +fade_speed = 0.2 +on_signal = "opened" +fade_out_signal = "closed" +on_signal = "opened" + +[node name="SlideEffect" parent="." node_paths=PackedStringArray("target") instance=ExtResource("3_4s56l")] +target = NodePath("../Spacer") +slide_speed = 0.4 +margin = 0 +direction = "up" +on_signal = "opened" +slide_out_signal = "closed" +on_signal = "opened" + +[node name="Spacer" type="Control" parent="."] +layout_mode = 3 +anchors_preset = 0 +offset_right = 40.0 +offset_bottom = 40.0 + +[node name="CenterContainer" type="CenterContainer" parent="Spacer"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = -188.5 +offset_top = -58.5 +offset_right = 148.5 +offset_bottom = 18.5 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="PanelContainer" type="PanelContainer" parent="Spacer/CenterContainer"] +custom_minimum_size = Vector2(450, 0) +layout_mode = 2 +theme_type_variation = &"Dialog" + +[node name="MarginContainer" type="MarginContainer" parent="Spacer/CenterContainer/PanelContainer"] +layout_mode = 2 +theme_override_constants/margin_left = 20 +theme_override_constants/margin_top = 20 +theme_override_constants/margin_right = 20 +theme_override_constants/margin_bottom = 20 + +[node name="ScrollContainer" type="ScrollContainer" parent="Spacer/CenterContainer/PanelContainer/MarginContainer"] +unique_name_in_owner = true +custom_minimum_size = Vector2(410, 506) +layout_mode = 2 +follow_focus = true +horizontal_scroll_mode = 0 + +[node name="MarginContainer" type="MarginContainer" parent="Spacer/CenterContainer/PanelContainer/MarginContainer/ScrollContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/margin_left = 5 +theme_override_constants/margin_right = 5 + +[node name="ContentContainer" type="VBoxContainer" parent="Spacer/CenterContainer/PanelContainer/MarginContainer/ScrollContainer/MarginContainer"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 20 + +[node name="FocusGroup" parent="Spacer/CenterContainer/PanelContainer/MarginContainer/ScrollContainer/MarginContainer/ContentContainer" instance=ExtResource("4_aao6t")] + +[node name="Label" type="Label" parent="Spacer/CenterContainer/PanelContainer/MarginContainer/ScrollContainer/MarginContainer/ContentContainer"] +unique_name_in_owner = true +custom_minimum_size = Vector2(100, 0) +layout_mode = 2 +text = "Select a location to install" +horizontal_alignment = 1 +autowrap_mode = 3 + +[node name="InstallLocationCard" parent="Spacer/CenterContainer/PanelContainer/MarginContainer/ScrollContainer/MarginContainer/ContentContainer" instance=ExtResource("4_8utsh")] +layout_mode = 2 + +[node name="InstallLocationCard2" parent="Spacer/CenterContainer/PanelContainer/MarginContainer/ScrollContainer/MarginContainer/ContentContainer" instance=ExtResource("4_8utsh")] +layout_mode = 2 + +[node name="CancelButton" parent="Spacer/CenterContainer/PanelContainer/MarginContainer/ScrollContainer/MarginContainer/ContentContainer" instance=ExtResource("4_krh2n")] +unique_name_in_owner = true +layout_mode = 2 +text = "Cancel" diff --git a/core/ui/components/install_options_dialog.gd b/core/ui/components/install_options_dialog.gd new file mode 100644 index 00000000..56edf3ad --- /dev/null +++ b/core/ui/components/install_options_dialog.gd @@ -0,0 +1,192 @@ +@icon("res://assets/editor-icons/solar--dialog-2-bold.svg") +@tool +extends Control +class_name InstallOptionDialog + +## Emitted when the dialog window is opened +signal opened +## Emitted when the dialog window is closed +signal closed +## Emitted when the user selects an option +signal choice_selected(accepted: bool, choices: Dictionary) + +## Text to display in the dialog box +@export var text: String: + set(v): + text = v + if label: + label.text = v +## Cancel button text +@export var cancel_text: String = "Cancel": + set(v): + cancel_text = v + if cancel_button: + cancel_button.text = v +@export var cancel_visible: bool = true: + set(v): + cancel_visible = v + if cancel_button: + cancel_button.visible = v +## Close the dialog when the user selects an option +@export var close_on_selected := true +## Maximum size that the scroll container can grow to +@export var custom_maximum_size: Vector2i: + set(v): + custom_maximum_size = v + if scroll_container: + _recalculate_minimum_size() + +@onready var scroll_container := $%ScrollContainer as ScrollContainer +@onready var label := $%Label as Label +@onready var confirm_button := $%ConfirmButton as CardButton +@onready var cancel_button := $%CancelButton as CardButton +@onready var fade_effect := $%FadeEffect as Effect +@onready var content := $%VBoxContainer as Control +@onready var focus_group := $%FocusGroup as FocusGroup + +var _permanent_nodes := ["Label", "ConfirmButton", "CancelButton", "FocusGroup"] +var _return_node: Control = null +var _install_options: Array[Library.InstallOption] = [] +var _selected_options: Dictionary = {} + +# Called when the node enters the scene tree for the first time. +func _ready() -> void: + confirm_button.button_up.connect(_on_selected.bind(true)) + cancel_button.button_up.connect(_on_selected.bind(false)) + scroll_container.sort_children.connect(_recalculate_minimum_size) + + +## Invoked when confirm or cancel is selected +func _on_selected(accepted: bool) -> void: + if close_on_selected: + closed.emit() + + # Build the selected options + if accepted: + _update_selected_options() + + choice_selected.emit(accepted, self._selected_options.duplicate()) + if _return_node: + _return_node.grab_focus.call_deferred() + _return_node = null + _install_options.clear() + _selected_options.clear() + _clear_option_nodes() + + +## Update the _selected_options dictionary with the user's current choices +func _update_selected_options() -> void: + for child: Node in content.get_children(): + if child.name in _permanent_nodes: + continue + var option := child.get_meta("option") as Library.InstallOption + if not option: + continue + if option.id.is_empty(): + continue + + if child is Dropdown: + var option_button := (child as Dropdown).option_button + var selected := option_button.get_item_text(option_button.selected) + self._selected_options[option.id] = selected + + if child is Toggle: + self._selected_options[option.id] = (child as Toggle).button_pressed + + +## Clear any previous menu items +func _clear_option_nodes() -> void: + for child: Node in content.get_children(): + if child.name in _permanent_nodes: + continue + content.remove_child(child) + child.queue_free() + + +## Creates a [Control] node to configure the given [Library.InstallOption]. +func _create_option_node(option: Library.InstallOption) -> Control: + var option_node: Control + var values := option.values + match option.value_type: + TYPE_BOOL: + var toggle_scene := load("res://core/ui/components/toggle.tscn") as PackedScene + var toggle := toggle_scene.instantiate() as Toggle + toggle.text = option.name + toggle.description = option.description + var on_toggled := func(pressed: bool): + self._selected_options[option.id] = pressed + toggle.toggled.connect(on_toggled) + option_node = toggle + TYPE_STRING: + var dropdown_scene := load("res://core/ui/components/dropdown.tscn") as PackedScene + var dropdown := dropdown_scene.instantiate() as Dropdown + var on_ready := func(): + dropdown.clear() + dropdown.title = option.name + dropdown.description = option.description + for value in values: + dropdown.add_item(str(value)) + var on_selected := func(idx: int): + self._selected_options[option.id] = values[idx] + dropdown.item_selected.connect(on_selected) + dropdown.ready.connect(on_ready, CONNECT_ONE_SHOT) + option_node = dropdown + _: + pass + + if option_node: + option_node.set_meta("option", option) + + return option_node + + +## Updates the minimum size of the scroll container up to the custom_maximum_size. +## This will allow the scroll container to dynamically grow based on the content +## inside the scroll container up to a maximum size. +func _recalculate_minimum_size() -> void: + if custom_maximum_size == Vector2i.ZERO: + return + if scroll_container.get_child_count() < 1: + return + + # Scroll containers only support having one child node + var child := scroll_container.get_child(0) + if not child is Control: + return + var child_size := (child as Control).size + + # Check the size of the child to see if the max size has been reached. If not, + # adjust the size of the scroll container based on the content. + if custom_maximum_size.x != 0.0 and child_size.x > custom_maximum_size.x: + scroll_container.custom_minimum_size.x = custom_maximum_size.x + else: + scroll_container.custom_minimum_size.x = child_size.x + + if custom_maximum_size.y != 0.0 and child_size.y > custom_maximum_size.y: + scroll_container.custom_minimum_size.y = custom_maximum_size.y + else: + scroll_container.custom_minimum_size.y = child_size.y + + +## Opens the dialog box with the given settings +func open(return_node: Control, options: Array[Library.InstallOption], message: String = "") -> void: + self._install_options = options + self._selected_options.clear() + + # Clear any previous menu items + _clear_option_nodes() + + # Build the menu based on the install options + for option in options: + var option_node := _create_option_node(option) + if option_node: + content.add_child(option_node) + content.move_child(confirm_button, -1) + content.move_child(cancel_button, -1) + + if message != "": + text = message + _return_node = return_node + opened.emit() + await fade_effect.effect_finished + confirm_button.grab_focus.call_deferred() diff --git a/core/ui/components/install_options_dialog.tscn b/core/ui/components/install_options_dialog.tscn new file mode 100644 index 00000000..19c950fd --- /dev/null +++ b/core/ui/components/install_options_dialog.tscn @@ -0,0 +1,111 @@ +[gd_scene load_steps=7 format=3 uid="uid://18axsy5my1x6"] + +[ext_resource type="Script" path="res://core/ui/components/install_options_dialog.gd" id="1_khr1a"] +[ext_resource type="PackedScene" uid="uid://bw8113ocotx2r" path="res://core/systems/effects/fade_effect.tscn" id="2_2dmlt"] +[ext_resource type="PackedScene" uid="uid://c71ayw7pcw6u6" path="res://core/ui/components/card_button.tscn" id="2_gupdw"] +[ext_resource type="PackedScene" uid="uid://ekhjpmat02f8" path="res://core/systems/effects/slide_effect.tscn" id="3_yy6n3"] +[ext_resource type="PackedScene" uid="uid://8m20p2s0v5gb" path="res://core/systems/input/focus_group.tscn" id="4_ap5s1"] +[ext_resource type="PackedScene" uid="uid://xei5afwefxud" path="res://core/ui/components/dropdown.tscn" id="5_pwq2o"] + +[node name="InstallOptionsDialog" type="Control"] +layout_mode = 3 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_khr1a") +text = "Select a location to install" +custom_maximum_size = Vector2i(0, 600) + +[node name="FadeEffect" parent="." node_paths=PackedStringArray("target") instance=ExtResource("2_2dmlt")] +unique_name_in_owner = true +target = NodePath("..") +fade_speed = 0.2 +on_signal = "opened" +fade_out_signal = "closed" +on_signal = "opened" + +[node name="SlideEffect" parent="." node_paths=PackedStringArray("target") instance=ExtResource("3_yy6n3")] +target = NodePath("../Spacer") +slide_speed = 0.4 +margin = 0 +direction = "up" +on_signal = "opened" +slide_out_signal = "closed" +on_signal = "opened" + +[node name="Spacer" type="Control" parent="."] +layout_mode = 3 +anchors_preset = 0 +offset_right = 40.0 +offset_bottom = 40.0 + +[node name="CenterContainer" type="CenterContainer" parent="Spacer"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = -188.5 +offset_top = -58.5 +offset_right = 148.5 +offset_bottom = 18.5 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="PanelContainer" type="PanelContainer" parent="Spacer/CenterContainer"] +custom_minimum_size = Vector2(450, 0) +layout_mode = 2 +theme_type_variation = &"Dialog" + +[node name="MarginContainer" type="MarginContainer" parent="Spacer/CenterContainer/PanelContainer"] +layout_mode = 2 +theme_override_constants/margin_left = 20 +theme_override_constants/margin_top = 20 +theme_override_constants/margin_right = 20 +theme_override_constants/margin_bottom = 20 + +[node name="ScrollContainer" type="ScrollContainer" parent="Spacer/CenterContainer/PanelContainer/MarginContainer"] +unique_name_in_owner = true +custom_minimum_size = Vector2(410, 188) +layout_mode = 2 + +[node name="MarginContainer" type="MarginContainer" parent="Spacer/CenterContainer/PanelContainer/MarginContainer/ScrollContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/margin_left = 5 +theme_override_constants/margin_right = 5 + +[node name="VBoxContainer" type="VBoxContainer" parent="Spacer/CenterContainer/PanelContainer/MarginContainer/ScrollContainer/MarginContainer"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +theme_override_constants/separation = 10 + +[node name="FocusGroup" parent="Spacer/CenterContainer/PanelContainer/MarginContainer/ScrollContainer/MarginContainer/VBoxContainer" node_paths=PackedStringArray("current_focus") instance=ExtResource("4_ap5s1")] +unique_name_in_owner = true +current_focus = NodePath("../ConfirmButton") + +[node name="Label" type="Label" parent="Spacer/CenterContainer/PanelContainer/MarginContainer/ScrollContainer/MarginContainer/VBoxContainer"] +unique_name_in_owner = true +custom_minimum_size = Vector2(100, 0) +layout_mode = 2 +text = "Install Options" +horizontal_alignment = 1 +autowrap_mode = 3 + +[node name="Dropdown" parent="Spacer/CenterContainer/PanelContainer/MarginContainer/ScrollContainer/MarginContainer/VBoxContainer" instance=ExtResource("5_pwq2o")] +layout_mode = 2 + +[node name="ConfirmButton" parent="Spacer/CenterContainer/PanelContainer/MarginContainer/ScrollContainer/MarginContainer/VBoxContainer" instance=ExtResource("2_gupdw")] +unique_name_in_owner = true +layout_mode = 2 +text = "OK" + +[node name="CancelButton" parent="Spacer/CenterContainer/PanelContainer/MarginContainer/ScrollContainer/MarginContainer/VBoxContainer" instance=ExtResource("2_gupdw")] +unique_name_in_owner = true +layout_mode = 2 +text = "Cancel"