Godot 4.7 project for authoring, previewing, validating, and building Exchanger PCK themes. Includes the example and space-pixel themes, binding contracts, editor tooling, validation/build scripts, and project documentation.
1119 lines
41 KiB
GDScript
1119 lines
41 KiB
GDScript
@tool
|
||
extends EditorPlugin
|
||
|
||
const THEMES_ROOT := "res://themes"
|
||
const EXAMPLE_THEME_ID := "example"
|
||
const ACTIVE_STATE_PATH := "res://build/active-theme.json"
|
||
const ACTIVATE_SCRIPT_PATH := "res://activate-theme.ps1"
|
||
const BUILD_SCRIPT_PATH := "res://build-theme.ps1"
|
||
const PREVIEW_SCENE_PATH := "res://tools/preview/preview.tscn"
|
||
|
||
const MENU_SEPARATOR_ID := 1_947_300_100
|
||
const MENU_SELECT_ID := 1_947_300_101
|
||
const MENU_CREATE_ID := 1_947_300_102
|
||
const MENU_APPLY_ID := 1_947_300_103
|
||
const MENU_EXPORT_ID := 1_947_300_104
|
||
const SELECT_MENU_TEXT := "Выбрать тему"
|
||
const CREATE_MENU_TEXT := "Создать тему"
|
||
const EXPORT_MENU_TEXT := "Экспортировать тему"
|
||
const APPLY_MENU_TEXT := "Применить тему к Exchanger"
|
||
const SELECT_DIALOG_SIZE := Vector2i(700, 390)
|
||
const CREATE_DIALOG_SIZE := Vector2i(700, 470)
|
||
const EXPORT_THEME_DIALOG_SIZE := Vector2i(720, 430)
|
||
const EXPORT_FILE_DIALOG_SIZE := Vector2i(900, 620)
|
||
const APPLY_DIALOG_SIZE := Vector2i(720, 430)
|
||
const PROGRESS_DIALOG_SIZE := Vector2i(700, 330)
|
||
const MESSAGE_DIALOG_SIZE := Vector2i(680, 260)
|
||
const BUILD_PROGRESS_POLL_INTERVAL := 0.1
|
||
const BUILD_STAGE_LABELS := {
|
||
"select": "Выбор исходников темы…",
|
||
"prepare": "Подготовка пространства сборки…",
|
||
"import": "Импорт ресурсов темы в Godot…",
|
||
"validate": "Проверка manifest, сцен и биндингов…",
|
||
"export": "Экспорт production-ресурсов в PCK…",
|
||
"verify": "Проверка готового PCK…",
|
||
"finalize": "Завершение локальной сборки…",
|
||
"install": "Установка темы для Exchanger…",
|
||
"complete": "Тема успешно собрана",
|
||
"error": "Сборка завершилась с ошибкой",
|
||
}
|
||
|
||
var _editor_menu: PopupMenu
|
||
var _uses_standard_tools_menu := false
|
||
var _select_dialog: ConfirmationDialog
|
||
var _theme_options: OptionButton
|
||
var _select_details: RichTextLabel
|
||
var _create_dialog: ConfirmationDialog
|
||
var _theme_id_input: LineEdit
|
||
var _display_name_input: LineEdit
|
||
var _create_validation: Label
|
||
var _create_details: RichTextLabel
|
||
var _export_theme_dialog: ConfirmationDialog
|
||
var _export_theme_options: OptionButton
|
||
var _export_theme_details: RichTextLabel
|
||
var _export_file_dialog: FileDialog
|
||
var _apply_dialog: ConfirmationDialog
|
||
var _apply_details: RichTextLabel
|
||
var _progress_dialog: AcceptDialog
|
||
var _progress_bar: ProgressBar
|
||
var _progress_stage: Label
|
||
var _progress_details: RichTextLabel
|
||
var _message_dialog: AcceptDialog
|
||
var _theme_id_regex := RegEx.new()
|
||
var _busy := false
|
||
var _pending_export_theme_id := ""
|
||
var _pending_apply_theme_id := ""
|
||
var _build_thread: Thread
|
||
var _active_build_theme_id := ""
|
||
var _active_build_output_path := ""
|
||
var _active_build_installs := false
|
||
var _active_progress_path := ""
|
||
var _progress_poll_elapsed := 0.0
|
||
|
||
|
||
func _enter_tree() -> void:
|
||
set_process(false)
|
||
_theme_id_regex.compile("^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
||
_remove_legacy_theme_ignores()
|
||
_create_dialogs()
|
||
call_deferred("_install_menu_items")
|
||
|
||
|
||
func _exit_tree() -> void:
|
||
_remove_menu_items()
|
||
if _build_thread != null and _build_thread.is_started():
|
||
_build_thread.wait_to_finish()
|
||
_free_dialog(_select_dialog)
|
||
_free_dialog(_create_dialog)
|
||
_free_dialog(_export_theme_dialog)
|
||
_free_dialog(_export_file_dialog)
|
||
_free_dialog(_apply_dialog)
|
||
_free_dialog(_progress_dialog)
|
||
_free_dialog(_message_dialog)
|
||
|
||
|
||
func _install_menu_items() -> void:
|
||
_editor_menu = _find_editor_menu(EditorInterface.get_base_control())
|
||
if _editor_menu != null:
|
||
_remove_direct_menu_item(MENU_SELECT_ID)
|
||
_remove_direct_menu_item(MENU_CREATE_ID)
|
||
_remove_direct_menu_item(MENU_EXPORT_ID)
|
||
_remove_direct_menu_item(MENU_APPLY_ID)
|
||
_remove_direct_menu_item(MENU_SEPARATOR_ID)
|
||
_editor_menu.add_separator("Темы Exchanger", MENU_SEPARATOR_ID)
|
||
_editor_menu.add_item(SELECT_MENU_TEXT, MENU_SELECT_ID)
|
||
_editor_menu.add_item(CREATE_MENU_TEXT, MENU_CREATE_ID)
|
||
_editor_menu.add_item(EXPORT_MENU_TEXT, MENU_EXPORT_ID)
|
||
_editor_menu.add_item(APPLY_MENU_TEXT, MENU_APPLY_ID)
|
||
if not _editor_menu.id_pressed.is_connected(_on_editor_menu_id_pressed):
|
||
_editor_menu.id_pressed.connect(_on_editor_menu_id_pressed)
|
||
print("[Theme Manager] Commands added to the Editor menu.")
|
||
return
|
||
|
||
_uses_standard_tools_menu = true
|
||
add_tool_menu_item(SELECT_MENU_TEXT, _show_select_dialog)
|
||
add_tool_menu_item(CREATE_MENU_TEXT, _show_create_dialog)
|
||
add_tool_menu_item(EXPORT_MENU_TEXT, _show_export_dialog)
|
||
add_tool_menu_item(APPLY_MENU_TEXT, _show_apply_dialog)
|
||
push_warning("[Theme Manager] Editor menu was not found; commands were added to Project > Tools.")
|
||
|
||
|
||
func _remove_menu_items() -> void:
|
||
if _editor_menu != null and is_instance_valid(_editor_menu):
|
||
_remove_direct_menu_item(MENU_SELECT_ID)
|
||
_remove_direct_menu_item(MENU_CREATE_ID)
|
||
_remove_direct_menu_item(MENU_EXPORT_ID)
|
||
_remove_direct_menu_item(MENU_APPLY_ID)
|
||
_remove_direct_menu_item(MENU_SEPARATOR_ID)
|
||
if _editor_menu.id_pressed.is_connected(_on_editor_menu_id_pressed):
|
||
_editor_menu.id_pressed.disconnect(_on_editor_menu_id_pressed)
|
||
if _uses_standard_tools_menu:
|
||
remove_tool_menu_item(SELECT_MENU_TEXT)
|
||
remove_tool_menu_item(CREATE_MENU_TEXT)
|
||
remove_tool_menu_item(EXPORT_MENU_TEXT)
|
||
remove_tool_menu_item(APPLY_MENU_TEXT)
|
||
_uses_standard_tools_menu = false
|
||
_editor_menu = null
|
||
|
||
|
||
func _remove_direct_menu_item(item_id: int) -> void:
|
||
if _editor_menu == null or not is_instance_valid(_editor_menu):
|
||
return
|
||
var item_index := _editor_menu.get_item_index(item_id)
|
||
if item_index >= 0:
|
||
_editor_menu.remove_item(item_index)
|
||
|
||
|
||
func _find_editor_menu(root: Node) -> PopupMenu:
|
||
var pending: Array[Node] = [root]
|
||
while not pending.is_empty():
|
||
var node := pending.pop_back() as Node
|
||
if node is MenuBar:
|
||
var menu_bar := node as MenuBar
|
||
for menu_index in range(menu_bar.get_menu_count()):
|
||
var title := menu_bar.get_menu_title(menu_index).replace("&", "").strip_edges().to_lower()
|
||
if title == "editor" or title == "редактор":
|
||
return menu_bar.get_menu_popup(menu_index)
|
||
for child in node.get_children():
|
||
pending.push_back(child)
|
||
return null
|
||
|
||
|
||
func _remove_legacy_theme_ignores() -> void:
|
||
var themes_directory := DirAccess.open(THEMES_ROOT)
|
||
if themes_directory == null:
|
||
return
|
||
var removed_count := 0
|
||
themes_directory.list_dir_begin()
|
||
while true:
|
||
var directory_name := themes_directory.get_next()
|
||
if directory_name.is_empty():
|
||
break
|
||
if not themes_directory.current_is_dir() or directory_name.begins_with("."):
|
||
continue
|
||
var ignore_path := THEMES_ROOT.path_join(directory_name).path_join(".gdignore")
|
||
if not FileAccess.file_exists(ignore_path):
|
||
continue
|
||
var remove_error := DirAccess.remove_absolute(ProjectSettings.globalize_path(ignore_path))
|
||
if remove_error == OK:
|
||
removed_count += 1
|
||
else:
|
||
push_warning("[Theme Manager] Не удалось удалить %s: %s" % [ignore_path, error_string(remove_error)])
|
||
themes_directory.list_dir_end()
|
||
if removed_count > 0:
|
||
print("[Theme Manager] Removed %d legacy .gdignore file(s); themes are visible in FileSystem." % removed_count)
|
||
EditorInterface.get_resource_filesystem().scan()
|
||
|
||
|
||
func _on_editor_menu_id_pressed(item_id: int) -> void:
|
||
match item_id:
|
||
MENU_SELECT_ID:
|
||
_show_select_dialog()
|
||
MENU_CREATE_ID:
|
||
_show_create_dialog()
|
||
MENU_EXPORT_ID:
|
||
_show_export_dialog()
|
||
MENU_APPLY_ID:
|
||
_show_apply_dialog()
|
||
|
||
|
||
func _create_dialogs() -> void:
|
||
var editor_root := EditorInterface.get_base_control()
|
||
|
||
_select_dialog = ConfirmationDialog.new()
|
||
_select_dialog.title = "Выбрать тему Exchanger"
|
||
_configure_compact_dialog(_select_dialog, SELECT_DIALOG_SIZE)
|
||
_select_dialog.get_ok_button().text = "Выбрать"
|
||
_select_dialog.confirmed.connect(_on_select_confirmed)
|
||
editor_root.add_child(_select_dialog)
|
||
|
||
var select_content := VBoxContainer.new()
|
||
select_content.custom_minimum_size = Vector2(620, 280)
|
||
select_content.add_theme_constant_override("separation", 10)
|
||
_select_dialog.add_child(select_content)
|
||
var select_hint := Label.new()
|
||
select_hint.text = "Выберите тему, с которой будет работать preview и команды редактора."
|
||
select_content.add_child(select_hint)
|
||
_theme_options = OptionButton.new()
|
||
_theme_options.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
_theme_options.item_selected.connect(_on_select_theme_changed)
|
||
select_content.add_child(_theme_options)
|
||
_select_details = _make_details_panel(150)
|
||
select_content.add_child(_select_details)
|
||
select_content.add_child(_make_steps_label([
|
||
"Остановка запущенного preview",
|
||
"Обновление active-theme.json",
|
||
"Сканирование ресурсов и открытие preview",
|
||
]))
|
||
|
||
_create_dialog = ConfirmationDialog.new()
|
||
_create_dialog.title = "Создать тему Exchanger"
|
||
_configure_compact_dialog(_create_dialog, CREATE_DIALOG_SIZE)
|
||
_create_dialog.get_ok_button().text = "Создать и выбрать"
|
||
_create_dialog.confirmed.connect(_on_create_confirmed)
|
||
editor_root.add_child(_create_dialog)
|
||
|
||
var create_content := VBoxContainer.new()
|
||
create_content.custom_minimum_size = Vector2(620, 360)
|
||
create_content.add_theme_constant_override("separation", 7)
|
||
_create_dialog.add_child(create_content)
|
||
var create_hint := Label.new()
|
||
create_hint.text = "Новая тема будет копией example: 9 полностью оформленных сцен и 481 биндинг."
|
||
create_content.add_child(create_hint)
|
||
create_content.add_child(_make_field_label("ID темы"))
|
||
_theme_id_input = LineEdit.new()
|
||
_theme_id_input.placeholder_text = "например, my-theme"
|
||
_theme_id_input.text_changed.connect(_on_create_field_changed)
|
||
create_content.add_child(_theme_id_input)
|
||
create_content.add_child(_make_field_label("Название темы"))
|
||
_display_name_input = LineEdit.new()
|
||
_display_name_input.placeholder_text = "Например, Моя тема"
|
||
_display_name_input.text_changed.connect(_on_create_field_changed)
|
||
create_content.add_child(_display_name_input)
|
||
_create_validation = Label.new()
|
||
create_content.add_child(_create_validation)
|
||
_create_details = _make_details_panel(105)
|
||
_create_details.text = (
|
||
"Этапы: проверка ID → копирование example → запись manifest.json → "
|
||
+ "активация темы → открытие preview.\nИсходники будут созданы в themes/<id>/package/."
|
||
)
|
||
create_content.add_child(_create_details)
|
||
|
||
_export_theme_dialog = ConfirmationDialog.new()
|
||
_export_theme_dialog.title = "Экспортировать тему Exchanger"
|
||
_configure_compact_dialog(_export_theme_dialog, EXPORT_THEME_DIALOG_SIZE)
|
||
_export_theme_dialog.get_ok_button().text = "Выбрать файл…"
|
||
_export_theme_dialog.confirmed.connect(_on_export_theme_confirmed)
|
||
editor_root.add_child(_export_theme_dialog)
|
||
|
||
var export_content := VBoxContainer.new()
|
||
export_content.custom_minimum_size = Vector2(640, 320)
|
||
export_content.add_theme_constant_override("separation", 10)
|
||
_export_theme_dialog.add_child(export_content)
|
||
var export_hint := Label.new()
|
||
export_hint.text = "Можно экспортировать любую тему из themes/, независимо от активной темы preview."
|
||
export_content.add_child(export_hint)
|
||
_export_theme_options = OptionButton.new()
|
||
_export_theme_options.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
_export_theme_options.item_selected.connect(_on_export_theme_changed)
|
||
export_content.add_child(_export_theme_options)
|
||
_export_theme_details = _make_details_panel(155)
|
||
export_content.add_child(_export_theme_details)
|
||
export_content.add_child(_make_steps_label([
|
||
"Изолированный импорт ресурсов",
|
||
"Проверка manifest, 9 сцен и 481 биндинга",
|
||
"Сборка и проверка готового PCK",
|
||
]))
|
||
|
||
_export_file_dialog = FileDialog.new()
|
||
_export_file_dialog.title = "Сохранить тему как PCK"
|
||
_export_file_dialog.file_mode = FileDialog.FILE_MODE_SAVE_FILE
|
||
_export_file_dialog.access = FileDialog.ACCESS_FILESYSTEM
|
||
_export_file_dialog.filters = PackedStringArray(["*.pck ; Exchanger Theme PCK"])
|
||
_export_file_dialog.file_selected.connect(_on_export_file_selected)
|
||
_export_file_dialog.canceled.connect(_on_export_canceled)
|
||
editor_root.add_child(_export_file_dialog)
|
||
|
||
_apply_dialog = ConfirmationDialog.new()
|
||
_apply_dialog.title = "Применить тему к Exchanger"
|
||
_configure_compact_dialog(_apply_dialog, APPLY_DIALOG_SIZE)
|
||
_apply_dialog.get_ok_button().text = "Собрать и применить"
|
||
_apply_dialog.confirmed.connect(_on_apply_confirmed)
|
||
editor_root.add_child(_apply_dialog)
|
||
var apply_content := VBoxContainer.new()
|
||
apply_content.custom_minimum_size = Vector2(640, 320)
|
||
apply_content.add_theme_constant_override("separation", 10)
|
||
_apply_dialog.add_child(apply_content)
|
||
var apply_hint := Label.new()
|
||
apply_hint.text = "Активная тема будет полностью проверена, собрана и установлена для Exchanger."
|
||
apply_content.add_child(apply_hint)
|
||
_apply_details = _make_details_panel(170)
|
||
apply_content.add_child(_apply_details)
|
||
apply_content.add_child(_make_steps_label([
|
||
"Проверка и сборка PCK",
|
||
"Проверка содержимого готового пакета",
|
||
"Установка active_theme.pck в каталог Exchanger",
|
||
]))
|
||
|
||
_progress_dialog = AcceptDialog.new()
|
||
_progress_dialog.title = "Операция с темой"
|
||
_configure_compact_dialog(_progress_dialog, PROGRESS_DIALOG_SIZE)
|
||
_progress_dialog.get_ok_button().text = "Закрыть"
|
||
editor_root.add_child(_progress_dialog)
|
||
var progress_content := VBoxContainer.new()
|
||
progress_content.custom_minimum_size = Vector2(620, 220)
|
||
progress_content.add_theme_constant_override("separation", 12)
|
||
_progress_dialog.add_child(progress_content)
|
||
_progress_stage = Label.new()
|
||
_progress_stage.text = "Подготовка…"
|
||
progress_content.add_child(_progress_stage)
|
||
_progress_bar = ProgressBar.new()
|
||
_progress_bar.custom_minimum_size = Vector2(0, 28)
|
||
_progress_bar.min_value = 0.0
|
||
_progress_bar.max_value = 100.0
|
||
_progress_bar.show_percentage = true
|
||
progress_content.add_child(_progress_bar)
|
||
_progress_details = _make_details_panel(120)
|
||
progress_content.add_child(_progress_details)
|
||
|
||
_message_dialog = AcceptDialog.new()
|
||
_message_dialog.title = "Темы Exchanger"
|
||
_configure_compact_dialog(_message_dialog, MESSAGE_DIALOG_SIZE)
|
||
editor_root.add_child(_message_dialog)
|
||
|
||
|
||
func _configure_compact_dialog(dialog: Window, maximum_size: Vector2i) -> void:
|
||
# Godot 4.7 can calculate a huge first-popup minimum for an unlaid-out
|
||
# autowrapped Label. Keep plugin dialogs explicitly bounded instead.
|
||
dialog.wrap_controls = false
|
||
dialog.unresizable = true
|
||
dialog.max_size = maximum_size
|
||
|
||
|
||
func _make_field_label(text: String) -> Label:
|
||
var label := Label.new()
|
||
label.text = text
|
||
return label
|
||
|
||
|
||
func _make_details_panel(minimum_height: float) -> RichTextLabel:
|
||
var details := RichTextLabel.new()
|
||
details.custom_minimum_size = Vector2(0, minimum_height)
|
||
details.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
details.bbcode_enabled = true
|
||
details.fit_content = false
|
||
details.scroll_active = false
|
||
return details
|
||
|
||
|
||
func _make_steps_label(steps: Array[String]) -> Label:
|
||
var label := Label.new()
|
||
var lines: Array[String] = ["Этапы операции:"]
|
||
for step_index in range(steps.size()):
|
||
lines.append("%d. %s" % [step_index + 1, steps[step_index]])
|
||
label.text = "\n".join(lines)
|
||
return label
|
||
|
||
|
||
func _free_dialog(dialog: Window) -> void:
|
||
if dialog != null and is_instance_valid(dialog):
|
||
dialog.queue_free()
|
||
|
||
|
||
func _show_select_dialog() -> void:
|
||
if _busy:
|
||
_show_message("Операция уже выполняется.", true)
|
||
return
|
||
_refresh_theme_options()
|
||
if _theme_options.item_count == 0:
|
||
_show_message("В themes/ не найдено ни одной валидной темы manifest v2.", true)
|
||
return
|
||
_select_dialog.popup_centered_clamped(SELECT_DIALOG_SIZE, 0.9)
|
||
|
||
|
||
func _refresh_theme_options() -> void:
|
||
_theme_options.clear()
|
||
var active_theme_id := _read_active_theme_id()
|
||
var selected_index := 0
|
||
for theme in _list_themes():
|
||
var item_index := _theme_options.item_count
|
||
var theme_id: String = theme["id"]
|
||
var display_name: String = theme["display_name"]
|
||
_theme_options.add_item("%s — %s" % [display_name, theme_id])
|
||
_theme_options.set_item_metadata(item_index, theme_id)
|
||
if theme_id == active_theme_id:
|
||
selected_index = item_index
|
||
_theme_options.select(selected_index)
|
||
_on_select_theme_changed(selected_index)
|
||
|
||
|
||
func _on_select_theme_changed(selected_index: int) -> void:
|
||
if selected_index < 0 or selected_index >= _theme_options.item_count:
|
||
_select_details.text = ""
|
||
return
|
||
var theme_id := str(_theme_options.get_item_metadata(selected_index))
|
||
_select_details.text = _format_theme_details(theme_id, theme_id == _read_active_theme_id())
|
||
|
||
|
||
func _list_themes() -> Array[Dictionary]:
|
||
var result: Array[Dictionary] = []
|
||
var themes_directory := DirAccess.open(THEMES_ROOT)
|
||
if themes_directory == null:
|
||
return result
|
||
themes_directory.list_dir_begin()
|
||
while true:
|
||
var directory_name := themes_directory.get_next()
|
||
if directory_name.is_empty():
|
||
break
|
||
if not themes_directory.current_is_dir() or directory_name.begins_with("."):
|
||
continue
|
||
var manifest_path := THEMES_ROOT.path_join(directory_name).path_join("package/manifest.json")
|
||
var manifest := _read_json_dictionary(manifest_path)
|
||
if manifest.is_empty():
|
||
continue
|
||
if manifest.get("Format", "") != "ExchangerThemeDlc" or int(manifest.get("FormatVersion", 0)) != 2:
|
||
continue
|
||
if str(manifest.get("ThemeId", "")) != directory_name:
|
||
continue
|
||
result.append({
|
||
"id": directory_name,
|
||
"display_name": str(manifest.get("DisplayName", directory_name)),
|
||
"format_version": int(manifest.get("FormatVersion", 0)),
|
||
})
|
||
themes_directory.list_dir_end()
|
||
result.sort_custom(_sort_theme_records)
|
||
return result
|
||
|
||
|
||
func _sort_theme_records(left: Dictionary, right: Dictionary) -> bool:
|
||
return str(left["id"]) < str(right["id"])
|
||
|
||
|
||
func _read_active_theme_id() -> String:
|
||
var state := _read_json_dictionary(ACTIVE_STATE_PATH)
|
||
return str(state.get("ThemeId", ""))
|
||
|
||
|
||
func _find_theme_record(theme_id: String) -> Dictionary:
|
||
for theme in _list_themes():
|
||
if str(theme["id"]) == theme_id:
|
||
return theme
|
||
return {}
|
||
|
||
|
||
func _format_theme_details(theme_id: String, is_active: bool) -> String:
|
||
var theme := _find_theme_record(theme_id)
|
||
if theme.is_empty():
|
||
return "[color=#ff8877]Тема с ID %s больше не найдена.[/color]" % theme_id
|
||
var status := "[color=#83e69a]активна в preview[/color]" if is_active else "не активна в preview"
|
||
return (
|
||
"[b]%s[/b]\nID: [code]%s[/code]\nФормат: ExchangerThemeDlc v%d\nСтатус: %s\n"
|
||
+ "Production-исходники: [code]themes/%s/package/[/code]"
|
||
) % [theme["display_name"], theme_id, theme["format_version"], status, theme_id]
|
||
|
||
|
||
func _on_select_confirmed() -> void:
|
||
var selected_index := _theme_options.selected
|
||
if selected_index < 0:
|
||
return
|
||
var theme_id := str(_theme_options.get_item_metadata(selected_index))
|
||
_activate_theme(theme_id)
|
||
|
||
|
||
func _show_create_dialog() -> void:
|
||
if _busy:
|
||
_show_message("Операция уже выполняется.", true)
|
||
return
|
||
_theme_id_input.clear()
|
||
_display_name_input.clear()
|
||
_update_create_validation()
|
||
_create_dialog.popup_centered_clamped(CREATE_DIALOG_SIZE, 0.9)
|
||
_theme_id_input.grab_focus()
|
||
|
||
|
||
func _on_create_field_changed(_new_text: String) -> void:
|
||
_update_create_validation()
|
||
|
||
|
||
func _update_create_validation() -> void:
|
||
var theme_id := _theme_id_input.text.strip_edges()
|
||
var display_name := _display_name_input.text.strip_edges()
|
||
var validation_message := ""
|
||
if theme_id.is_empty():
|
||
validation_message = "Введите ID темы."
|
||
elif _theme_id_regex.search(theme_id) == null:
|
||
validation_message = "ID: строчные латинские буквы, цифры и одиночные дефисы."
|
||
elif DirAccess.dir_exists_absolute(ProjectSettings.globalize_path(THEMES_ROOT.path_join(theme_id))):
|
||
validation_message = "Тема с таким ID уже существует."
|
||
elif display_name.is_empty():
|
||
validation_message = "Введите название темы."
|
||
|
||
_create_validation.text = validation_message if not validation_message.is_empty() else "Будет создан themes/%s." % theme_id
|
||
_create_validation.modulate = Color(1.0, 0.55, 0.45) if not validation_message.is_empty() else Color(0.55, 0.9, 0.6)
|
||
_create_dialog.get_ok_button().disabled = not validation_message.is_empty()
|
||
if validation_message.is_empty():
|
||
_create_details.text = (
|
||
"Будет создана тема [b]%s[/b] с ID [code]%s[/code].\n"
|
||
+ "Источник: [code]themes/example/[/code]\nНазначение: [code]themes/%s/[/code]\n"
|
||
+ "После копирования тема сразу станет активной и откроется в preview."
|
||
) % [display_name, theme_id, theme_id]
|
||
else:
|
||
_create_details.text = (
|
||
"Этапы: проверка ID → копирование example → запись manifest.json → "
|
||
+ "активация темы → открытие preview.\nИсходники будут созданы в themes/<id>/package/."
|
||
)
|
||
|
||
|
||
func _on_create_confirmed() -> void:
|
||
var theme_id := _theme_id_input.text.strip_edges()
|
||
var display_name := _display_name_input.text.strip_edges()
|
||
if _busy:
|
||
return
|
||
_create_and_activate_theme(theme_id, display_name)
|
||
|
||
|
||
func _show_export_dialog() -> void:
|
||
if _busy:
|
||
_show_message("Операция уже выполняется.", true)
|
||
return
|
||
_refresh_export_theme_options()
|
||
if _export_theme_options.item_count == 0:
|
||
_show_message("В themes/ не найдено ни одной валидной темы manifest v2.", true)
|
||
return
|
||
_export_theme_dialog.popup_centered_clamped(EXPORT_THEME_DIALOG_SIZE, 0.9)
|
||
|
||
|
||
func _refresh_export_theme_options() -> void:
|
||
_export_theme_options.clear()
|
||
var active_theme_id := _read_active_theme_id()
|
||
var selected_index := 0
|
||
for theme in _list_themes():
|
||
var item_index := _export_theme_options.item_count
|
||
var theme_id := str(theme["id"])
|
||
_export_theme_options.add_item("%s — ID: %s" % [theme["display_name"], theme_id])
|
||
_export_theme_options.set_item_metadata(item_index, theme_id)
|
||
if theme_id == active_theme_id:
|
||
selected_index = item_index
|
||
_export_theme_options.select(selected_index)
|
||
_on_export_theme_changed(selected_index)
|
||
|
||
|
||
func _on_export_theme_changed(selected_index: int) -> void:
|
||
if selected_index < 0 or selected_index >= _export_theme_options.item_count:
|
||
_export_theme_details.text = ""
|
||
return
|
||
var theme_id := str(_export_theme_options.get_item_metadata(selected_index))
|
||
_export_theme_details.text = (
|
||
_format_theme_details(theme_id, theme_id == _read_active_theme_id())
|
||
+ "\n\nЭкспорт этой темы не переключит текущий preview."
|
||
)
|
||
|
||
|
||
func _on_export_theme_confirmed() -> void:
|
||
var selected_index := _export_theme_options.selected
|
||
if selected_index < 0:
|
||
return
|
||
_pending_export_theme_id = str(_export_theme_options.get_item_metadata(selected_index))
|
||
var theme := _find_theme_record(_pending_export_theme_id)
|
||
if theme.is_empty():
|
||
_pending_export_theme_id = ""
|
||
_show_message("Выбранная тема больше не найдена. Обновите список и повторите экспорт.", true)
|
||
return
|
||
_export_file_dialog.title = "Экспортировать «%s» — %s" % [theme["display_name"], _pending_export_theme_id]
|
||
_export_file_dialog.current_dir = ProjectSettings.globalize_path("res://build")
|
||
_export_file_dialog.current_file = "%s.pck" % _pending_export_theme_id
|
||
_export_file_dialog.popup_centered_clamped(EXPORT_FILE_DIALOG_SIZE, 0.9)
|
||
|
||
|
||
func _on_export_file_selected(selected_path: String) -> void:
|
||
if _pending_export_theme_id.is_empty():
|
||
return
|
||
var theme_id := _pending_export_theme_id
|
||
_pending_export_theme_id = ""
|
||
var output_path := selected_path.simplify_path()
|
||
if output_path.get_extension().to_lower() != "pck":
|
||
output_path += ".pck"
|
||
_start_theme_build(theme_id, output_path, false)
|
||
|
||
|
||
func _on_export_canceled() -> void:
|
||
_pending_export_theme_id = ""
|
||
|
||
|
||
func _show_apply_dialog() -> void:
|
||
if _busy:
|
||
_show_message("Операция уже выполняется.", true)
|
||
return
|
||
|
||
var active_theme := _get_active_theme_record()
|
||
if active_theme.is_empty():
|
||
_show_message("Сначала выберите тему через «Редактор → Выбрать тему».", true)
|
||
return
|
||
|
||
var active_theme_id := str(active_theme["id"])
|
||
_pending_apply_theme_id = active_theme_id
|
||
_apply_details.text = (
|
||
"[b]%s[/b]\nID: [code]%s[/code]\n\n"
|
||
+ "Файл назначения:\n[code]%s[/code]\n\n"
|
||
+ "[color=#ffd27d]Перед применением закройте Exchanger. После установки запустите его заново.[/color]"
|
||
) % [active_theme["display_name"], active_theme_id, _get_default_install_path()]
|
||
_apply_dialog.popup_centered_clamped(APPLY_DIALOG_SIZE, 0.9)
|
||
|
||
|
||
func _on_apply_confirmed() -> void:
|
||
if _busy or _pending_apply_theme_id.is_empty():
|
||
return
|
||
var theme_id := _pending_apply_theme_id
|
||
_pending_apply_theme_id = ""
|
||
_start_theme_build(theme_id, "", true)
|
||
|
||
|
||
func _get_active_theme_record() -> Dictionary:
|
||
var active_theme_id := _read_active_theme_id()
|
||
for theme in _list_themes():
|
||
if str(theme["id"]) == active_theme_id:
|
||
return theme
|
||
return {}
|
||
|
||
|
||
func _start_theme_build(theme_id: String, output_path: String, install_to_exchanger: bool) -> void:
|
||
if _busy:
|
||
return
|
||
_busy = true
|
||
_active_build_theme_id = theme_id
|
||
_active_build_output_path = output_path
|
||
_active_build_installs = install_to_exchanger
|
||
_active_progress_path = ProjectSettings.globalize_path(
|
||
"res://build/theme-manager-progress-%d.json" % OS.get_process_id()
|
||
).simplify_path()
|
||
if FileAccess.file_exists(_active_progress_path):
|
||
DirAccess.remove_absolute(_active_progress_path)
|
||
_progress_poll_elapsed = 0.0
|
||
var operation_title := "Сборка и применение темы" if install_to_exchanger else "Экспорт темы"
|
||
var destination := _get_default_install_path() if install_to_exchanger else output_path
|
||
_begin_operation_progress(
|
||
operation_title,
|
||
"Подготовка запуска…",
|
||
"Тема: %s\nНазначение: %s\nОжидание запуска PowerShell-сценария."
|
||
% [theme_id, destination],
|
||
2,
|
||
)
|
||
|
||
var script_path := ProjectSettings.globalize_path(BUILD_SCRIPT_PATH)
|
||
_build_thread = Thread.new()
|
||
var start_error := _build_thread.start(
|
||
_build_theme_in_thread.bind(
|
||
theme_id,
|
||
script_path,
|
||
output_path,
|
||
install_to_exchanger,
|
||
_active_progress_path,
|
||
)
|
||
)
|
||
if start_error != OK:
|
||
_build_thread = null
|
||
_busy = false
|
||
_clear_active_build()
|
||
_finish_operation_progress(
|
||
"Не удалось запустить сборку",
|
||
"Ошибка запуска фоновой задачи: %s." % error_string(start_error),
|
||
true,
|
||
)
|
||
return
|
||
set_process(true)
|
||
|
||
|
||
func _process(delta: float) -> void:
|
||
if _build_thread == null:
|
||
return
|
||
_progress_poll_elapsed += delta
|
||
if _progress_poll_elapsed >= BUILD_PROGRESS_POLL_INTERVAL:
|
||
_progress_poll_elapsed = 0.0
|
||
_poll_build_progress()
|
||
if _build_thread.is_alive():
|
||
return
|
||
_poll_build_progress()
|
||
var thread_result: Variant = _build_thread.wait_to_finish()
|
||
_build_thread = null
|
||
set_process(false)
|
||
_busy = false
|
||
|
||
var result: Dictionary = {}
|
||
if thread_result is Dictionary:
|
||
result = thread_result
|
||
var exit_code := int(result.get("exit_code", -1))
|
||
var output := str(result.get("output", ""))
|
||
if exit_code != 0:
|
||
var failure_heading := "Применение темы не выполнено" if _active_build_installs else "Экспорт темы не выполнен"
|
||
_finish_operation_progress(
|
||
failure_heading,
|
||
"Код ошибки: %d\n\n%s" % [exit_code, _format_output_tail(output)],
|
||
true,
|
||
)
|
||
_clear_active_build()
|
||
return
|
||
|
||
EditorInterface.get_resource_filesystem().scan()
|
||
if _active_build_installs:
|
||
_finish_operation_progress(
|
||
"Тема собрана и применена",
|
||
"ID: %s\nФайл: %s\n\nТеперь запустите Exchanger заново."
|
||
% [_active_build_theme_id, _get_default_install_path()],
|
||
)
|
||
else:
|
||
if not FileAccess.file_exists(_active_build_output_path):
|
||
_finish_operation_progress(
|
||
"Экспорт не подтверждён",
|
||
"Сборка завершилась, но экспортированный PCK не найден:\n%s"
|
||
% _active_build_output_path,
|
||
true,
|
||
)
|
||
else:
|
||
_finish_operation_progress(
|
||
"Тема успешно экспортирована",
|
||
"ID: %s\nГотовый PCK:\n%s"
|
||
% [_active_build_theme_id, _active_build_output_path],
|
||
)
|
||
_clear_active_build()
|
||
|
||
|
||
func _build_theme_in_thread(
|
||
theme_id: String,
|
||
script_path: String,
|
||
output_path: String,
|
||
install_to_exchanger: bool,
|
||
progress_path: String,
|
||
) -> Dictionary:
|
||
var output: Array = []
|
||
var arguments := PackedStringArray([
|
||
"-NoLogo",
|
||
"-NoProfile",
|
||
"-ExecutionPolicy",
|
||
"Bypass",
|
||
"-File",
|
||
script_path,
|
||
"-ThemeId",
|
||
theme_id,
|
||
"-ProgressPath",
|
||
progress_path,
|
||
])
|
||
if not output_path.is_empty():
|
||
arguments.append("-OutputPath")
|
||
arguments.append(output_path)
|
||
if not install_to_exchanger:
|
||
arguments.append("-PreserveActiveTheme")
|
||
if install_to_exchanger:
|
||
arguments.append("-InstallToDefaultUserPath")
|
||
var exit_code := OS.execute(
|
||
"powershell.exe",
|
||
arguments,
|
||
output,
|
||
true,
|
||
false,
|
||
)
|
||
var combined_output := ""
|
||
for line in output:
|
||
combined_output += str(line)
|
||
return {
|
||
"exit_code": exit_code,
|
||
"output": combined_output.strip_edges(),
|
||
}
|
||
|
||
|
||
func _clear_active_build() -> void:
|
||
if not _active_progress_path.is_empty() and FileAccess.file_exists(_active_progress_path):
|
||
DirAccess.remove_absolute(_active_progress_path)
|
||
_active_build_theme_id = ""
|
||
_active_build_output_path = ""
|
||
_active_build_installs = false
|
||
_active_progress_path = ""
|
||
_progress_poll_elapsed = 0.0
|
||
|
||
|
||
func _get_default_install_path() -> String:
|
||
var app_data := OS.get_environment("APPDATA")
|
||
if app_data.is_empty():
|
||
return "%APPDATA%\\Godot\\app_userdata\\Exchanger\\theme_dlc\\active_theme.pck"
|
||
return app_data.path_join("Godot/app_userdata/Exchanger/theme_dlc/active_theme.pck")
|
||
|
||
|
||
func _format_output_tail(output: String, maximum_lines := 10) -> String:
|
||
var stripped := output.strip_edges()
|
||
if stripped.is_empty():
|
||
return "PowerShell не вернул диагностический вывод."
|
||
var lines := stripped.split("\n", false)
|
||
var first_line := maxi(0, lines.size() - maximum_lines)
|
||
return "\n".join(lines.slice(first_line))
|
||
|
||
|
||
func _poll_build_progress() -> void:
|
||
if _active_progress_path.is_empty() or not FileAccess.file_exists(_active_progress_path):
|
||
return
|
||
var progress := _read_json_dictionary(_active_progress_path)
|
||
if progress.is_empty():
|
||
return
|
||
var stage_id := str(progress.get("Stage", "build"))
|
||
# Windows PowerShell 5.1 читает UTF-8 scripts без BOM через системную
|
||
# code page. Поэтому пользовательский текст берём только из GDScript,
|
||
# а JSON сборщика используем как ASCII-протокол идентификаторов этапов.
|
||
var message := str(BUILD_STAGE_LABELS.get(stage_id, "Выполняется сборка…"))
|
||
var percent := int(progress.get("Percent", 0))
|
||
var destination := str(progress.get("OutputPath", _active_build_output_path))
|
||
if _active_build_installs:
|
||
destination = _get_default_install_path()
|
||
_update_operation_progress(
|
||
message,
|
||
"Тема: %s\nНазначение: %s\nПрогресс обновляется автоматически."
|
||
% [_active_build_theme_id, destination],
|
||
percent,
|
||
)
|
||
|
||
|
||
func _begin_operation_progress(
|
||
title: String,
|
||
stage: String,
|
||
details: String,
|
||
percent: int = 0,
|
||
) -> void:
|
||
_message_dialog.hide()
|
||
_progress_dialog.hide()
|
||
_progress_dialog.title = title
|
||
_progress_dialog.get_ok_button().disabled = true
|
||
_progress_stage.modulate = Color.WHITE
|
||
_update_operation_progress(stage, details, percent)
|
||
_progress_dialog.popup_centered_clamped(PROGRESS_DIALOG_SIZE, 0.9)
|
||
|
||
|
||
func _update_operation_progress(stage: String, details: String, percent: int) -> void:
|
||
_progress_stage.text = stage
|
||
_progress_bar.value = clampi(percent, 0, 100)
|
||
_progress_details.text = details
|
||
|
||
|
||
func _finish_operation_progress(title: String, details: String, is_error := false) -> void:
|
||
_progress_dialog.title = "Ошибка управления темами" if is_error else title
|
||
_progress_stage.text = title
|
||
_progress_stage.modulate = Color(1.0, 0.55, 0.45) if is_error else Color(0.55, 0.9, 0.6)
|
||
_progress_bar.value = 100.0
|
||
_progress_details.text = details
|
||
_progress_dialog.get_ok_button().disabled = false
|
||
if not _progress_dialog.visible:
|
||
_progress_dialog.popup_centered_clamped(PROGRESS_DIALOG_SIZE, 0.9)
|
||
|
||
|
||
func _create_and_activate_theme(theme_id: String, display_name: String) -> void:
|
||
_busy = true
|
||
_begin_operation_progress(
|
||
"Создание темы",
|
||
"Проверка параметров новой темы…",
|
||
"ID: %s\nНазвание: %s" % [theme_id, display_name],
|
||
5,
|
||
)
|
||
var created: bool = await _create_theme(theme_id, display_name)
|
||
if not created:
|
||
_busy = false
|
||
return
|
||
await _activate_theme(
|
||
theme_id,
|
||
"Тема «%s» создана и выбрана." % display_name,
|
||
true,
|
||
)
|
||
|
||
|
||
func _create_theme(theme_id: String, display_name: String) -> bool:
|
||
_update_operation_progress(
|
||
"Проверка ID и путей…",
|
||
"ID: %s\nПроверяется themes/%s/." % [theme_id, theme_id],
|
||
10,
|
||
)
|
||
await get_tree().process_frame
|
||
if _theme_id_regex.search(theme_id) == null or display_name.is_empty():
|
||
_finish_operation_progress("Тема не создана", "Некорректные данные новой темы.", true)
|
||
return false
|
||
|
||
var source_path := ProjectSettings.globalize_path(THEMES_ROOT.path_join(EXAMPLE_THEME_ID)).simplify_path()
|
||
var themes_path := ProjectSettings.globalize_path(THEMES_ROOT).simplify_path()
|
||
var destination_path := themes_path.path_join(theme_id).simplify_path()
|
||
if not DirAccess.dir_exists_absolute(source_path):
|
||
_finish_operation_progress("Тема не создана", "Не найдена тема-шаблон themes/example.", true)
|
||
return false
|
||
if destination_path.get_base_dir() != themes_path or DirAccess.dir_exists_absolute(destination_path):
|
||
_finish_operation_progress(
|
||
"Тема не создана",
|
||
"Небезопасный или уже существующий путь новой темы.",
|
||
true,
|
||
)
|
||
return false
|
||
|
||
_update_operation_progress(
|
||
"Копирование темы-шаблона…",
|
||
"Источник: themes/example/\nНазначение: themes/%s/" % theme_id,
|
||
30,
|
||
)
|
||
await get_tree().process_frame
|
||
var copy_error := _copy_directory_recursive(source_path, destination_path)
|
||
if copy_error != OK:
|
||
_remove_created_theme(destination_path, themes_path)
|
||
_finish_operation_progress(
|
||
"Тема не создана",
|
||
"Не удалось скопировать example: ошибка %s." % error_string(copy_error),
|
||
true,
|
||
)
|
||
return false
|
||
|
||
_update_operation_progress(
|
||
"Запись manifest.json…",
|
||
"Назначаются ThemeId и DisplayName новой темы.",
|
||
55,
|
||
)
|
||
await get_tree().process_frame
|
||
var manifest_path := destination_path.path_join("package/manifest.json")
|
||
var manifest := _read_json_dictionary(manifest_path)
|
||
if manifest.is_empty():
|
||
_remove_created_theme(destination_path, themes_path)
|
||
_finish_operation_progress(
|
||
"Тема не создана",
|
||
"В example отсутствует корректный manifest.json.",
|
||
true,
|
||
)
|
||
return false
|
||
manifest["ThemeId"] = theme_id
|
||
manifest["DisplayName"] = display_name
|
||
var manifest_file := FileAccess.open(manifest_path, FileAccess.WRITE)
|
||
if manifest_file == null:
|
||
_remove_created_theme(destination_path, themes_path)
|
||
_finish_operation_progress("Тема не создана", "Не удалось записать manifest новой темы.", true)
|
||
return false
|
||
manifest_file.store_string(JSON.stringify(manifest, " ", false) + "\n")
|
||
_update_operation_progress(
|
||
"Тема создана. Выполняется активация…",
|
||
"Исходники готовы: themes/%s/package/." % theme_id,
|
||
65,
|
||
)
|
||
return true
|
||
|
||
|
||
func _copy_directory_recursive(source_path: String, destination_path: String) -> Error:
|
||
var make_error := DirAccess.make_dir_recursive_absolute(destination_path)
|
||
if make_error != OK:
|
||
return make_error
|
||
var source_directory := DirAccess.open(source_path)
|
||
if source_directory == null:
|
||
return DirAccess.get_open_error()
|
||
source_directory.list_dir_begin()
|
||
while true:
|
||
var entry_name := source_directory.get_next()
|
||
if entry_name.is_empty():
|
||
break
|
||
if (
|
||
entry_name == "."
|
||
or entry_name == ".."
|
||
or entry_name == ".gdignore"
|
||
or entry_name.get_extension() == "import"
|
||
or entry_name.get_extension() == "uid"
|
||
):
|
||
continue
|
||
var source_entry := source_path.path_join(entry_name)
|
||
var destination_entry := destination_path.path_join(entry_name)
|
||
var entry_error := OK
|
||
if source_directory.current_is_dir():
|
||
entry_error = _copy_directory_recursive(source_entry, destination_entry)
|
||
else:
|
||
entry_error = DirAccess.copy_absolute(source_entry, destination_entry)
|
||
if entry_error != OK:
|
||
source_directory.list_dir_end()
|
||
return entry_error
|
||
source_directory.list_dir_end()
|
||
return OK
|
||
|
||
|
||
func _remove_created_theme(destination_path: String, themes_path: String) -> void:
|
||
if destination_path.get_base_dir() != themes_path:
|
||
return
|
||
_remove_directory_recursive(destination_path)
|
||
|
||
|
||
func _remove_directory_recursive(directory_path: String) -> void:
|
||
var directory := DirAccess.open(directory_path)
|
||
if directory == null:
|
||
return
|
||
directory.list_dir_begin()
|
||
while true:
|
||
var entry_name := directory.get_next()
|
||
if entry_name.is_empty():
|
||
break
|
||
if entry_name == "." or entry_name == "..":
|
||
continue
|
||
var entry_path := directory_path.path_join(entry_name)
|
||
if directory.current_is_dir():
|
||
_remove_directory_recursive(entry_path)
|
||
else:
|
||
DirAccess.remove_absolute(entry_path)
|
||
directory.list_dir_end()
|
||
DirAccess.remove_absolute(directory_path)
|
||
|
||
|
||
func _activate_theme(
|
||
theme_id: String,
|
||
success_message: String = "",
|
||
continuing_operation: bool = false,
|
||
) -> void:
|
||
if _busy and not continuing_operation:
|
||
return
|
||
_busy = true
|
||
if not continuing_operation:
|
||
_begin_operation_progress(
|
||
"Выбор темы",
|
||
"Подготовка preview…",
|
||
"Тема: %s" % theme_id,
|
||
5,
|
||
)
|
||
var was_playing := EditorInterface.is_playing_scene()
|
||
_update_operation_progress(
|
||
"Остановка запущенного preview…" if was_playing else "Подготовка редактора…",
|
||
"Тема: %s\nТекущее состояние preview сохранено." % theme_id,
|
||
70 if continuing_operation else 15,
|
||
)
|
||
await get_tree().process_frame
|
||
if was_playing:
|
||
EditorInterface.stop_playing_scene()
|
||
await get_tree().process_frame
|
||
|
||
_update_operation_progress(
|
||
"Активация темы…",
|
||
"Обновляется build/active-theme.json для ID %s." % theme_id,
|
||
78 if continuing_operation else 40,
|
||
)
|
||
await get_tree().process_frame
|
||
var output: Array = []
|
||
var script_path := ProjectSettings.globalize_path(ACTIVATE_SCRIPT_PATH)
|
||
var exit_code := OS.execute(
|
||
"powershell.exe",
|
||
PackedStringArray([
|
||
"-NoLogo",
|
||
"-NoProfile",
|
||
"-ExecutionPolicy",
|
||
"Bypass",
|
||
"-File",
|
||
script_path,
|
||
"-ThemeId",
|
||
theme_id,
|
||
]),
|
||
output,
|
||
true,
|
||
false,
|
||
)
|
||
if exit_code != 0:
|
||
_busy = false
|
||
_finish_operation_progress(
|
||
"Тема не выбрана",
|
||
"Не удалось выбрать тему «%s».\n\n%s" % [theme_id, _join_output(output)],
|
||
true,
|
||
)
|
||
return
|
||
|
||
_update_operation_progress(
|
||
"Обновление ресурсов редактора…",
|
||
"Godot сканирует package выбранной темы и открывает общую preview-сцену.",
|
||
90 if continuing_operation else 75,
|
||
)
|
||
EditorInterface.get_resource_filesystem().scan()
|
||
await get_tree().process_frame
|
||
EditorInterface.open_scene_from_path(PREVIEW_SCENE_PATH)
|
||
if was_playing:
|
||
_update_operation_progress(
|
||
"Повторный запуск preview…",
|
||
"Ожидание обновления импортированных ресурсов.",
|
||
96 if continuing_operation else 90,
|
||
)
|
||
await get_tree().create_timer(0.6).timeout
|
||
EditorInterface.play_main_scene()
|
||
|
||
_busy = false
|
||
var message := success_message
|
||
if message.is_empty():
|
||
message = "Тема «%s» выбрана и открыта напрямую из package." % theme_id
|
||
_finish_operation_progress("Тема выбрана", message)
|
||
|
||
|
||
func _join_output(output: Array) -> String:
|
||
var text := ""
|
||
for line in output:
|
||
text += str(line)
|
||
return text.strip_edges()
|
||
|
||
|
||
func _read_json_dictionary(path: String) -> Dictionary:
|
||
if not FileAccess.file_exists(path):
|
||
return {}
|
||
var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(path))
|
||
return parsed if parsed is Dictionary else {}
|
||
|
||
|
||
func _show_message(message: String, is_error := false) -> void:
|
||
_message_dialog.hide()
|
||
_message_dialog.get_ok_button().disabled = false
|
||
_message_dialog.title = "Ошибка управления темами" if is_error else "Темы Exchanger"
|
||
_message_dialog.dialog_text = message
|
||
_message_dialog.popup_centered_clamped(MESSAGE_DIALOG_SIZE, 0.9)
|