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.
158 lines
5.0 KiB
GDScript
158 lines
5.0 KiB
GDScript
extends Control
|
|
|
|
const POLL_INTERVAL_SECONDS := 0.25
|
|
const RELOAD_DEBOUNCE_SECONDS := 0.45
|
|
const SELECTED_TAB_META := &"theme_preview_selected_tab"
|
|
const SELECTED_SETTINGS_TAB_META := &"theme_preview_selected_settings_tab"
|
|
const WATCHED_DIRECTORIES := [
|
|
"res://exchanger_theme_dlc",
|
|
"res://preview_scenes",
|
|
]
|
|
const WATCHED_FILES := [
|
|
"res://preview.tscn",
|
|
]
|
|
const WATCHED_EXTENSIONS := {
|
|
"jpeg": true,
|
|
"jpg": true,
|
|
"json": true,
|
|
"gd": true,
|
|
"mp3": true,
|
|
"ogg": true,
|
|
"otf": true,
|
|
"png": true,
|
|
"svg": true,
|
|
"tres": true,
|
|
"tscn": true,
|
|
"ttf": true,
|
|
"wav": true,
|
|
"webp": true,
|
|
}
|
|
const HASHED_EXTENSIONS := {
|
|
"json": true,
|
|
"tres": true,
|
|
"tscn": true,
|
|
}
|
|
|
|
var _poll_elapsed := 0.0
|
|
var _reload_countdown := -1.0
|
|
var _snapshot: Dictionary = {}
|
|
var _pending_changes: Dictionary = {}
|
|
|
|
|
|
func _ready() -> void:
|
|
_restore_selected_tab()
|
|
_snapshot = _capture_snapshot()
|
|
print("[Theme Preview] Live reload is active.")
|
|
|
|
|
|
func _process(delta: float) -> void:
|
|
if _reload_countdown >= 0.0:
|
|
_reload_countdown -= delta
|
|
if _reload_countdown <= 0.0:
|
|
_reload_preview()
|
|
return
|
|
|
|
_poll_elapsed += delta
|
|
if _poll_elapsed < POLL_INTERVAL_SECONDS:
|
|
return
|
|
|
|
_poll_elapsed = 0.0
|
|
var next_snapshot := _capture_snapshot()
|
|
var changed_paths := _find_changed_paths(_snapshot, next_snapshot)
|
|
_snapshot = next_snapshot
|
|
if changed_paths.is_empty():
|
|
return
|
|
|
|
for path in changed_paths:
|
|
_pending_changes[path] = true
|
|
_reload_countdown = RELOAD_DEBOUNCE_SECONDS
|
|
|
|
|
|
func _restore_selected_tab() -> void:
|
|
var screens := get_node_or_null("Screens") as TabContainer
|
|
if screens == null or screens.get_tab_count() == 0:
|
|
return
|
|
|
|
var selected_tab := int(get_tree().root.get_meta(SELECTED_TAB_META, screens.current_tab))
|
|
screens.current_tab = clampi(selected_tab, 0, screens.get_tab_count() - 1)
|
|
|
|
var settings_tabs := get_node_or_null("Screens/SETTINGS/Sections") as TabContainer
|
|
if settings_tabs != null and settings_tabs.get_tab_count() > 0:
|
|
var selected_settings_tab := int(
|
|
get_tree().root.get_meta(SELECTED_SETTINGS_TAB_META, settings_tabs.current_tab)
|
|
)
|
|
settings_tabs.current_tab = clampi(
|
|
selected_settings_tab,
|
|
0,
|
|
settings_tabs.get_tab_count() - 1
|
|
)
|
|
|
|
|
|
func _capture_snapshot() -> Dictionary:
|
|
var result: Dictionary = {}
|
|
for directory_path in WATCHED_DIRECTORIES:
|
|
_scan_directory(directory_path, result)
|
|
for file_path in WATCHED_FILES:
|
|
if FileAccess.file_exists(file_path):
|
|
result[file_path] = _file_fingerprint(file_path)
|
|
return result
|
|
|
|
|
|
func _scan_directory(directory_path: String, result: Dictionary) -> void:
|
|
if not DirAccess.dir_exists_absolute(directory_path):
|
|
return
|
|
|
|
for file_name in DirAccess.get_files_at(directory_path):
|
|
var file_path := directory_path.path_join(file_name)
|
|
var extension := file_name.get_extension().to_lower()
|
|
if WATCHED_EXTENSIONS.has(extension):
|
|
result[file_path] = _file_fingerprint(file_path)
|
|
|
|
for child_directory in DirAccess.get_directories_at(directory_path):
|
|
_scan_directory(directory_path.path_join(child_directory), result)
|
|
|
|
|
|
func _file_fingerprint(file_path: String) -> String:
|
|
var modified_time := FileAccess.get_modified_time(file_path)
|
|
var file := FileAccess.open(file_path, FileAccess.READ)
|
|
var file_size := file.get_length() if file != null else -1
|
|
var extension := file_path.get_extension().to_lower()
|
|
if HASHED_EXTENSIONS.has(extension):
|
|
return "%s:%s:%s" % [modified_time, file_size, FileAccess.get_md5(file_path)]
|
|
return "%s:%s" % [modified_time, file_size]
|
|
|
|
|
|
func _find_changed_paths(previous: Dictionary, current: Dictionary) -> PackedStringArray:
|
|
var changed := PackedStringArray()
|
|
for path in current:
|
|
if not previous.has(path) or previous[path] != current[path]:
|
|
changed.append(path)
|
|
for path in previous:
|
|
if not current.has(path):
|
|
changed.append(path)
|
|
return changed
|
|
|
|
|
|
func _reload_preview() -> void:
|
|
var screens := get_node_or_null("Screens") as TabContainer
|
|
if screens != null:
|
|
get_tree().root.set_meta(SELECTED_TAB_META, screens.current_tab)
|
|
|
|
var settings_tabs := get_node_or_null("Screens/SETTINGS/Sections") as TabContainer
|
|
if settings_tabs != null:
|
|
get_tree().root.set_meta(SELECTED_SETTINGS_TAB_META, settings_tabs.current_tab)
|
|
|
|
var changed_paths := PackedStringArray(_pending_changes.keys())
|
|
changed_paths.sort()
|
|
_pending_changes.clear()
|
|
_reload_countdown = -1.0
|
|
|
|
for path in changed_paths:
|
|
if ResourceLoader.exists(path):
|
|
ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_REPLACE_DEEP)
|
|
|
|
print("[Theme Preview] Reloading after changes: %s" % ", ".join(changed_paths))
|
|
var reload_error := get_tree().reload_current_scene()
|
|
if reload_error != OK:
|
|
push_error("[Theme Preview] Scene reload failed with error %s." % reload_error)
|