straywild/ui/data_management_dialog.gd

326 lines
9.6 KiB
GDScript3
Raw Normal View History

2026-09-02 07:19:25 -04:00
class_name DataManagementDialog
extends Control
const UtilityPageStyleType = preload("res://ui/utility_page_style.gd")
signal action_selected(action_id: StringName)
signal dismissed
enum ActionTone {
SECONDARY,
PRIMARY,
DANGER,
}
const DEFAULT_SIZE := Vector2(600.0, 330.0)
const MINIMUM_SIZE := Vector2(520.0, 250.0)
const MAXIMUM_SIZE := Vector2(680.0, 500.0)
const ACTION_HEIGHT: float = 44.0
var _dimmer: ColorRect
var _panel: PanelContainer
var _title_label: Label
var _body_scroll: ScrollContainer
var _body: VBoxContainer
var _description: Label
var _custom_content: VBoxContainer
var _actions: VBoxContainer
var _action_buttons: Array[Button] = []
var _cancel_action_id: StringName = StringName("")
var _action_callback: Callable
var _presentation_generation: int = 0
func _ready() -> void:
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
mouse_filter = Control.MOUSE_FILTER_STOP
# Settings panels are hosted inside several differently layered title and
# pause containers. Keep the data modal above that whole presentation, not
# merely above the panel that opened it.
z_index = 250
_build_layout()
hide()
func present(
title_text: String,
body_text: String,
actions: Array[Dictionary],
action_callback: Callable = Callable(),
custom_content: Control = null,
preferred_control: Control = null,
requested_size: Vector2 = DEFAULT_SIZE,
) -> void:
if not is_inside_tree() or is_queued_for_deletion():
push_warning("Data-management dialog is not available yet.")
return
if not is_node_ready():
call_deferred(
"present",
title_text,
body_text,
actions,
action_callback,
custom_content,
preferred_control,
requested_size,
)
return
_presentation_generation += 1
_action_callback = action_callback
_title_label.text = title_text
_description.text = body_text
_description.visible = not body_text.strip_edges().is_empty()
_replace_custom_content(custom_content)
_rebuild_actions(actions)
_panel.custom_minimum_size = _clamped_size(requested_size)
show()
_focus_preferred.call_deferred(preferred_control)
func dismiss() -> void:
if not visible:
return
_presentation_generation += 1
_action_callback = Callable()
_cancel_action_id = StringName("")
hide()
dismissed.emit()
func is_open() -> bool:
return visible
func cancel() -> bool:
if not visible:
return false
if _cancel_action_id.is_empty():
dismiss()
return true
_trigger_action(_cancel_action_id)
return true
func focused_control() -> Control:
if not is_inside_tree():
return null
var focus_owner: Control = get_viewport().gui_get_focus_owner()
return focus_owner if focus_owner != null and is_ancestor_of(focus_owner) else null
func _build_layout() -> void:
_dimmer = ColorRect.new()
_dimmer.name = "Dimmer"
_dimmer.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_dimmer.color = Color(0.0, 0.04, 0.06, 0.72)
_dimmer.mouse_filter = Control.MOUSE_FILTER_STOP
add_child(_dimmer)
var center := CenterContainer.new()
center.name = "Center"
center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
center.mouse_filter = Control.MOUSE_FILTER_PASS
add_child(center)
_panel = PanelContainer.new()
_panel.name = "Panel"
_panel.custom_minimum_size = DEFAULT_SIZE
_panel.mouse_filter = Control.MOUSE_FILTER_STOP
_panel.add_theme_stylebox_override(
"panel", UtilityPageStyleType.panel_style()
)
center.add_child(_panel)
var layout := VBoxContainer.new()
layout.name = "Layout"
layout.add_theme_constant_override("separation", 14)
_panel.add_child(layout)
_title_label = Label.new()
_title_label.name = "Title"
_title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_title_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_title_label.add_theme_font_override("font", UtilityPageStyleType.TuffyFont)
_title_label.add_theme_font_size_override("font_size", 29)
_title_label.add_theme_color_override(
"font_color", UtilityPageStyleType.OCEAN_TEXT_PRIMARY
)
layout.add_child(_title_label)
_body_scroll = ScrollContainer.new()
_body_scroll.name = "BodyScroll"
_body_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_body_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
_body_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
_body_scroll.follow_focus = true
layout.add_child(_body_scroll)
_body = VBoxContainer.new()
_body.name = "Body"
_body.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_body.add_theme_constant_override("separation", 12)
_body_scroll.add_child(_body)
_description = Label.new()
_description.name = "Description"
_description.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_description.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_description.add_theme_font_override("font", UtilityPageStyleType.TuffyFont)
_description.add_theme_font_size_override("font_size", 18)
_description.add_theme_color_override(
"font_color", UtilityPageStyleType.OCEAN_TEXT_SECONDARY
)
_body.add_child(_description)
_custom_content = VBoxContainer.new()
_custom_content.name = "CustomContent"
_custom_content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_custom_content.add_theme_constant_override("separation", 12)
_body.add_child(_custom_content)
_actions = VBoxContainer.new()
_actions.name = "Actions"
_actions.size_flags_horizontal = Control.SIZE_EXPAND_FILL
layout.add_child(_actions)
func _replace_custom_content(content: Control) -> void:
for child: Node in _custom_content.get_children():
_custom_content.remove_child(child)
child.queue_free()
if content == null:
return
content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_custom_content.add_child(content)
func _rebuild_actions(actions: Array[Dictionary]) -> void:
for child: Node in _actions.get_children():
_actions.remove_child(child)
child.queue_free()
_action_buttons.clear()
_cancel_action_id = StringName("")
var action_ids: Array[StringName] = []
var action_layout: BoxContainer
if actions.size() <= 2:
var row := HBoxContainer.new()
row.alignment = BoxContainer.ALIGNMENT_CENTER
row.add_theme_constant_override("separation", 12)
action_layout = row
else:
var column := VBoxContainer.new()
column.add_theme_constant_override("separation", 10)
action_layout = column
_actions.add_child(action_layout)
for entry: Dictionary in actions:
var action_id: StringName = StringName(str(entry.get("id", "")))
if action_id.is_empty():
continue
var button := Button.new()
button.name = "%sButton" % str(action_id).capitalize()
button.text = str(entry.get("text", action_id))
button.disabled = bool(entry.get("disabled", false))
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
button.custom_minimum_size = Vector2(0.0, ACTION_HEIGHT)
button.focus_mode = Control.FOCUS_ALL
_apply_action_style(button, int(entry.get("tone", ActionTone.PRIMARY)))
button.set_meta("data_action_id", action_id)
button.pressed.connect(_trigger_action.bind(action_id))
action_layout.add_child(button)
_action_buttons.append(button)
action_ids.append(action_id)
if bool(entry.get("cancel", false)):
_cancel_action_id = action_id
if _cancel_action_id.is_empty() and not action_ids.is_empty():
_cancel_action_id = action_ids.back()
func _apply_action_style(button: Button, tone: int) -> void:
UtilityPageStyleType.apply_ocean_button(button)
match tone:
ActionTone.SECONDARY:
for state: StringName in [&"normal", &"hover", &"pressed", &"focus"]:
var color := (
UtilityPageStyleType.OCEAN_PANEL_DEEP
if state == &"normal"
else UtilityPageStyleType.OCEAN_PANEL_MID
)
button.add_theme_stylebox_override(
state,
UtilityPageStyleType.ocean_button_style(color),
)
ActionTone.DANGER:
button.add_theme_stylebox_override(
"normal",
UtilityPageStyleType.ocean_button_style(
UtilityPageStyleType.OCEAN_DANGER
),
)
button.add_theme_stylebox_override(
"hover",
UtilityPageStyleType.ocean_button_style(Color("c95a64")),
)
button.add_theme_stylebox_override(
"pressed",
UtilityPageStyleType.ocean_button_style(Color("74313b")),
)
button.add_theme_stylebox_override(
"focus",
UtilityPageStyleType.ocean_button_style(Color("c95a64")),
)
func _trigger_action(action_id: StringName) -> void:
if not visible:
return
var generation: int = _presentation_generation
action_selected.emit(action_id)
if _action_callback.is_valid():
_action_callback.call(action_id)
if generation == _presentation_generation and visible:
dismiss()
func _focus_preferred(preferred_control: Control) -> void:
if not visible:
return
var focusable: Array[Control] = []
for node: Node in _custom_content.find_children("*", "Control", true, false):
var control := node as Control
if _is_focusable(control):
focusable.append(control)
for button: Button in _action_buttons:
if _is_focusable(button):
focusable.append(button)
for index: int in focusable.size():
var control: Control = focusable[index]
control.focus_previous = control.get_path_to(
focusable[wrapi(index - 1, 0, focusable.size())]
)
control.focus_next = control.get_path_to(
focusable[wrapi(index + 1, 0, focusable.size())]
)
if _is_focusable(preferred_control):
preferred_control.grab_focus()
elif not focusable.is_empty():
focusable.front().grab_focus()
func _is_focusable(control: Control) -> bool:
if (
control == null
or not control.is_visible_in_tree()
or control.focus_mode != Control.FOCUS_ALL
):
return false
var button := control as BaseButton
return button == null or not button.disabled
func _clamped_size(requested_size: Vector2) -> Vector2:
return Vector2(
clampf(requested_size.x, MINIMUM_SIZE.x, MAXIMUM_SIZE.x),
clampf(requested_size.y, MINIMUM_SIZE.y, MAXIMUM_SIZE.y),
)