Add Recipemanager that loads recipes from YAML

This commit is contained in:
JonShard
2026-07-17 13:54:14 +02:00
parent 840908e9ff
commit 4d06e72bc1
133 changed files with 9121 additions and 175 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"godotTools.editorPath.godot4": "c:\\Program Files (x86)\\Godot\\Godot_v4.7-stable_win64.exe"
}
+6 -11
View File
@@ -7,8 +7,7 @@
[ext_resource type="PackedScene" uid="uid://c25yxb0vt53vc" path="res://addons/godot-xr-tools/objects/grab_points/grab_point_hand_left.tscn" id="5_wqyr2"]
[ext_resource type="Animation" uid="uid://bediglpx0rj7i" path="res://addons/godot-xr-tools/hands/animations/left/Grip 5.res" id="6_ftab3"]
[ext_resource type="PackedScene" uid="uid://3lr2dhy62rhk" path="res://Prefabs/combinable_item.tscn" id="7_wb51u"]
[ext_resource type="Script" uid="uid://coidnv8b2yvxr" path="res://Prefabs/combine_recipe.gd" id="8_wqyr2"]
[ext_resource type="PackedScene" uid="uid://b3m2ag8g5rj4r" path="res://Items/hamburger.tscn" id="9_wb51u"]
[ext_resource type="PackedScene" uid="uid://bfj80jh13t6e5" path="res://Prefabs/food_item.tscn" id="8_t9y3x"]
[sub_resource type="CylinderShape3D" id="CylinderShape3D_vlqg6"]
height = 0.10708985
@@ -27,12 +26,6 @@ metadata/_custom_type_script = "uid://dvobm6vcfnqe8"
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_hoqox"]
albedo_color = Color(0.6784191, 0.54546416, 0.016760282, 1)
[sub_resource type="Resource" id="Resource_hwyyd"]
script = ExtResource("8_wqyr2")
ingredient_id = &"cooked_burger"
result = ExtResource("9_wb51u")
metadata/_custom_type_script = "uid://coidnv8b2yvxr"
[node name="BurgerBuns" unique_id=1088240294 instance=ExtResource("1_g1t48")]
[node name="CollisionShape3D" parent="." index="0"]
@@ -74,6 +67,8 @@ sides = 16
cone = true
material = SubResource("StandardMaterial3D_hoqox")
[node name="CombinableItem" parent="." index="4" unique_id=996936271 instance=ExtResource("7_wb51u")]
id = &"burger_buns"
recipes = Array[ExtResource("8_wqyr2")]([SubResource("Resource_hwyyd")])
[node name="FoodItem" parent="." index="4" unique_id=123456789 instance=ExtResource("8_t9y3x")]
id = "burger_buns"
type = 2
[node name="CombinableItem" parent="." index="5" unique_id=996936271 instance=ExtResource("7_wb51u")]
+8
View File
@@ -6,6 +6,8 @@
[ext_resource type="Script" uid="uid://dvobm6vcfnqe8" path="res://addons/godot-xr-tools/hands/poses/hand_pose_settings.gd" id="4_mgacb"]
[ext_resource type="PackedScene" uid="uid://ctw7nbntd5pcj" path="res://addons/godot-xr-tools/objects/grab_points/grab_point_hand_right.tscn" id="5_vw7i6"]
[ext_resource type="Animation" uid="uid://d1xnpyc08njjx" path="res://addons/godot-xr-tools/hands/animations/right/Grip 4.res" id="6_cp3eg"]
[ext_resource type="PackedScene" uid="uid://bfj80jh13t6e5" path="res://Prefabs/food_item.tscn" id="7_mde49"]
[ext_resource type="PackedScene" uid="uid://3lr2dhy62rhk" path="res://Prefabs/combinable_item.tscn" id="8_wmrff"]
[sub_resource type="CylinderShape3D" id="CylinderShape3D_gkni8"]
custom_solver_bias = 0.1
@@ -46,3 +48,9 @@ hand_pose = SubResource("Resource_lc22d")
[node name="GrabPointHandRight" parent="." index="3" unique_id=514404634 instance=ExtResource("5_vw7i6")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.06531982, 0.042291984, -0.08604182)
hand_pose = SubResource("Resource_qyiot")
[node name="FoodItem" parent="." index="4" unique_id=345678912 instance=ExtResource("7_mde49")]
id = "charcoal"
type = 2
[node name="CombinableItem" parent="." index="5" unique_id=996936271 instance=ExtResource("8_wmrff")]
+4 -14
View File
@@ -7,9 +7,7 @@
[ext_resource type="PackedScene" uid="uid://ctw7nbntd5pcj" path="res://addons/godot-xr-tools/objects/grab_points/grab_point_hand_right.tscn" id="5_a0a5b"]
[ext_resource type="Animation" uid="uid://d1xnpyc08njjx" path="res://addons/godot-xr-tools/hands/animations/right/Grip 4.res" id="6_bdp75"]
[ext_resource type="PackedScene" uid="uid://3lr2dhy62rhk" path="res://Prefabs/combinable_item.tscn" id="7_bdp75"]
[ext_resource type="PackedScene" uid="uid://cucc3gaqu2nab" path="res://Items/Charcoal.tscn" id="8_a0a5b"]
[ext_resource type="PackedScene" uid="uid://7earnjgdmwgx" path="res://Prefabs/cookable_item.tscn" id="10_65ccv"]
[ext_resource type="Script" uid="uid://coidnv8b2yvxr" path="res://Prefabs/combine_recipe.gd" id="11_recipe"]
[ext_resource type="PackedScene" uid="uid://bfj80jh13t6e5" path="res://Prefabs/food_item.tscn" id="11_vjw41"]
[sub_resource type="BoxShape3D" id="BoxShape3D_m1xbq"]
@@ -32,12 +30,6 @@ script = ExtResource("4_hc3f7")
closed_pose = ExtResource("6_bdp75")
metadata/_custom_type_script = "uid://dvobm6vcfnqe8"
[sub_resource type="Resource" id="Resource_i8ixk"]
script = ExtResource("11_recipe")
ingredient_id = &"cube"
result = ExtResource("8_a0a5b")
metadata/_custom_type_script = "uid://coidnv8b2yvxr"
[node name="PickableObject" unique_id=1675596942 groups=["platalbe_item"] instance=ExtResource("1_dbtw8")]
[node name="CollisionShape3D" parent="." index="0"]
@@ -54,12 +46,10 @@ hand_pose = SubResource("Resource_lc22d")
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.06531982, 0.042291984, -0.08604182)
hand_pose = SubResource("Resource_qyiot")
[node name="CombinableItem" parent="." index="4" unique_id=996936271 instance=ExtResource("7_bdp75")]
id = &"cube"
recipes = Array[ExtResource("11_recipe")]([SubResource("Resource_i8ixk")])
[node name="CookableItem" parent="." index="4" unique_id=280820828 instance=ExtResource("10_65ccv")]
[node name="CookableItem" parent="." index="5" unique_id=280820828 instance=ExtResource("10_65ccv")]
[node name="FoodItem" parent="." index="6" unique_id=63948206 instance=ExtResource("11_vjw41")]
[node name="FoodItem" parent="." index="5" unique_id=63948206 instance=ExtResource("11_vjw41")]
id = "cube"
type = 1
[node name="CombinableItem" parent="." index="6" unique_id=996936271 instance=ExtResource("7_bdp75")]
+7 -11
View File
@@ -7,9 +7,8 @@
[ext_resource type="PackedScene" uid="uid://ctw7nbntd5pcj" path="res://addons/godot-xr-tools/objects/grab_points/grab_point_hand_right.tscn" id="6_etdv2"]
[ext_resource type="Animation" uid="uid://d1xnpyc08njjx" path="res://addons/godot-xr-tools/hands/animations/right/Grip 4.res" id="7_rl64h"]
[ext_resource type="PackedScene" uid="uid://3lr2dhy62rhk" path="res://Prefabs/combinable_item.tscn" id="10_ut7mg"]
[ext_resource type="PackedScene" uid="uid://bfj80jh13t6e5" path="res://Prefabs/food_item.tscn" id="10_zz42p"]
[ext_resource type="PackedScene" uid="uid://7earnjgdmwgx" path="res://Prefabs/cookable_item.tscn" id="11_5ch2b"]
[ext_resource type="Script" uid="uid://coidnv8b2yvxr" path="res://Prefabs/combine_recipe.gd" id="11_rjayc"]
[ext_resource type="PackedScene" uid="uid://b3m2ag8g5rj4r" path="res://Items/hamburger.tscn" id="11_ut7mg"]
[sub_resource type="CylinderShape3D" id="CylinderShape3D_fco8w"]
height = 0.04777527
@@ -28,12 +27,6 @@ script = ExtResource("5_ychvb")
closed_pose = ExtResource("7_rl64h")
metadata/_custom_type_script = "uid://dvobm6vcfnqe8"
[sub_resource type="Resource" id="Resource_wi1yl"]
script = ExtResource("11_rjayc")
ingredient_id = &"burger_buns"
result = ExtResource("11_ut7mg")
metadata/_custom_type_script = "uid://coidnv8b2yvxr"
[node name="CookedBurger" unique_id=1675596942 instance=ExtResource("1_ut7mg")]
[node name="CollisionShape3D" parent="." index="0"]
@@ -54,7 +47,10 @@ hand_pose = SubResource("Resource_lc22d")
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.06531982, 0.042291984, -0.08604182)
hand_pose = SubResource("Resource_qyiot")
[node name="CombinableItem" parent="." index="4" unique_id=434304805 instance=ExtResource("10_ut7mg")]
recipes = Array[ExtResource("11_rjayc")]([SubResource("Resource_wi1yl")])
[node name="CookableItem" parent="." index="4" unique_id=280820828 instance=ExtResource("11_5ch2b")]
[node name="CookableItem" parent="." index="5" unique_id=280820828 instance=ExtResource("11_5ch2b")]
[node name="FoodItem" parent="." index="5" unique_id=234567891 instance=ExtResource("10_zz42p")]
id = "cooked_burger"
type = 2
[node name="CombinableItem" parent="." index="6" unique_id=996936271 instance=ExtResource("10_ut7mg")]
+107 -13
View File
@@ -1,24 +1,118 @@
class_name CombinableItem
extends Node
## Identity of this item, referenced by other items' recipes.
@export var id: StringName
const RECIPE_MANAGER = preload("res://RecipeManager.gd")
## Recipes describing what the item holding this component turns into when
## another item is combined into it while snapped. Recipes are directional:
## only the snapped (base) item's recipes are consulted.
@export var recipes: Array[CombineRecipe] = []
@onready var combine_area_3d: Area3D = $Area3d
@onready var _pickable: XRToolsPickable = get_parent() as XRToolsPickable
# Guard so a combine only fires once.
var _combining: bool = false
var _food_item: FoodItem
func _ready() -> void:
if id.is_empty():
push_error("CombinableItem on ", get_parent().name, " has no 'id' set.")
print("CombinableItem _ready(): ", _pickable.name)
_food_item = get_parent().get_node_or_null("FoodItem") as FoodItem
if not _food_item:
push_error("CombinableItem is missing FoodItem reference. must be a sibling of a FoodItem on ", get_parent().name, ".")
# Detect items whether held (layer 17 "Held Objects") or loose
# (layer 3 "Pickable Objects"). Don't advertise a layer of our own.
combine_area_3d.collision_mask = 0b0000_0000_0000_0001_0000_0000_0000_0100
combine_area_3d.collision_layer = 0
# Deferred: _ready can run during a physics signal flush (when a combine
# spawns the result), where toggling monitoring directly is forbidden.
set_deferred("monitoring", false)
set_deferred("monitorable", false)
if not _pickable:
push_error("CombineZone must be a grand child of an XRToolsPickable.")
return
if not _food_item:
push_error("CombineZone requires a FoodItem sibling on ", _pickable.name, ".")
return
_pickable.picked_up.connect(_on_item_picked_up)
_pickable.dropped.connect(_on_item_dropped)
combine_area_3d.body_entered.connect(_on_body_entered)
## Returns the scene this item becomes when combined with [param other_id],
## or null if there is no matching recipe.
func get_result_for(other_id: StringName) -> PackedScene:
for recipe in recipes:
if recipe and recipe.ingredient_id == other_id:
return recipe.result
return RECIPE_MANAGER.get_combination(_food_item.id, other_id)
## Trigger area that lets a snapped item combine with another item pushed into
## it. The zone only monitors while its owning item is held by an
## [XRToolsSnapZone]; when an item carrying a matching [FoodItem.id]
## enters, the snapped (base) item transforms into the recipe result and the
## incoming item is consumed.
# Enable the trigger only while snapped into a snap zone (not hand-held).
func _on_item_picked_up(_item: Node3D) -> void:
var by := _pickable.get_picked_up_by()
var snapped: bool = by != null and by.has_method("is_xr_class") and by.is_xr_class("XRToolsSnapZone")
set_deferred("monitoring", snapped)
func _on_item_dropped(_item: Node3D) -> void:
set_deferred("monitoring", false)
func _on_body_entered(body: Node3D) -> void:
print("CombinableItem _on_body_entered")
if _combining or body == _pickable:
print("CombinableItem _on_body_entered: other is our own pickable")
return
var other := _find_food_item(body)
if not other:
print("CombinableItem _on_body_entered: other is not a foodItem")
return
var result: PackedScene = RecipeManager.get_combination(_food_item.id, other.id)
if not result:
print("CombinableItem _on_body_entered: There is no recipe for %s + %s" % [_food_item.id, other.id])
return
_combine(body, result)
# Transform the base item into [param result], consuming the incoming item.
func _combine(other_body: Node3D, result: PackedScene) -> void:
print("CombinableItem _combine: combining %s + %s into %s" % [_food_item.id, _find_food_item(other_body).id, result.resource_path])
_combining = true
var snap_zone := _pickable.get_picked_up_by()
if not snap_zone or not snap_zone.has_method("pick_up_object"):
push_warning("CombineZone: base item is not held by a snap zone; cannot combine.")
_combining = false
return
# Spawn the result at the base item's location, in world space.
var base_transform := _pickable.global_transform
var result_instance: Node3D = result.instantiate()
_pickable.get_tree().current_scene.add_child(result_instance)
result_instance.global_transform = base_transform
# Free the base item and consume the incoming item.
snap_zone.drop_object()
_pickable.queue_free()
if other_body.has_method("drop_and_free"):
other_body.drop_and_free()
else:
other_body.queue_free()
# Snap the result into the now-empty zone.
snap_zone.pick_up_object(result_instance)
# Find a FoodItem among the direct children of [param node].
func _find_food_item(node: Node) -> FoodItem:
if not node:
return null
for child in node.get_children():
if child is FoodItem:
return child
return null
+2 -7
View File
@@ -1,19 +1,14 @@
[gd_scene format=3 uid="uid://3lr2dhy62rhk"]
[ext_resource type="Script" uid="uid://dounic663dn5q" path="res://Prefabs/combinable_item.gd" id="1_q21ti"]
[ext_resource type="Script" uid="uid://coidnv8b2yvxr" path="res://Prefabs/combine_recipe.gd" id="2_anbu7"]
[ext_resource type="Script" uid="uid://e3im02nq5cye" path="res://Prefabs/combine_zone.gd" id="4_0om48"]
[sub_resource type="BoxShape3D" id="BoxShape3D_combine"]
size = Vector3(0.16, 0.16, 0.16)
[node name="CombinableItem" type="Node3D" unique_id=996936271]
script = ExtResource("1_q21ti")
id = &"some_food_item"
recipes = Array[ExtResource("2_anbu7")]([null])
[node name="CombineZone" type="Area3D" parent="." unique_id=1514178016]
script = ExtResource("4_0om48")
[node name="Area3d" type="Area3D" parent="." unique_id=1514178016]
[node name="CollisionShape3D" type="CollisionShape3D" parent="CombineZone" unique_id=706892516]
[node name="CollisionShape3D" type="CollisionShape3D" parent="Area3d" unique_id=706892516]
shape = SubResource("BoxShape3D_combine")
-103
View File
@@ -1,103 +0,0 @@
class_name CombineZone
extends Area3D
## Trigger area that lets a snapped item combine with another item pushed into
## it. The zone only monitors while its owning item is held by an
## [XRToolsSnapZone]; when an item carrying a matching [CombinableItem.id]
## enters, the snapped (base) item transforms into the recipe result and the
## incoming item is consumed.
# The pickable this zone belongs to (its parent).
@onready var _item: XRToolsPickable = get_parent().get_parent() as XRToolsPickable
# The base item's recipe data.
@onready var _combinable: CombinableItem = _find_combinable(_item)
# Guard so a combine only fires once.
var _combining: bool = false
func _ready() -> void:
# Detect items whether held (layer 17 "Held Objects") or loose
# (layer 3 "Pickable Objects"). Don't advertise a layer of our own.
collision_mask = 0b0000_0000_0000_0001_0000_0000_0000_0100
collision_layer = 0
# Deferred: _ready can run during a physics signal flush (when a combine
# spawns the result), where toggling monitoring directly is forbidden.
set_deferred("monitoring", false)
set_deferred("monitorable", false)
if not _item:
push_error("CombineZone must be a grand child of an XRToolsPickable.")
return
if not _combinable:
push_error("CombineZone requires a CombinableItem sibling on ", _item.name, ".")
return
_item.picked_up.connect(_on_item_picked_up)
_item.dropped.connect(_on_item_dropped)
body_entered.connect(_on_body_entered)
# Enable the trigger only while snapped into a snap zone (not hand-held).
func _on_item_picked_up(_pickable: Node3D) -> void:
var by := _item.get_picked_up_by()
var snapped: bool = by != null and by.has_method("is_xr_class") and by.is_xr_class("XRToolsSnapZone")
set_deferred("monitoring", snapped)
func _on_item_dropped(_pickable: Node3D) -> void:
set_deferred("monitoring", false)
func _on_body_entered(body: Node3D) -> void:
if _combining or body == _item:
return
var other := _find_combinable(body)
if not other:
return
var result: PackedScene = _combinable.get_result_for(other.id)
if not result:
return
_combine(body, result)
# Transform the base item into [param result], consuming the incoming item.
func _combine(other_body: Node3D, result: PackedScene) -> void:
_combining = true
var snap_zone := _item.get_picked_up_by()
if not snap_zone or not snap_zone.has_method("pick_up_object"):
push_warning("CombineZone: base item is not held by a snap zone; cannot combine.")
_combining = false
return
# Spawn the result at the base item's location, in world space.
var base_transform := _item.global_transform
var result_instance: Node3D = result.instantiate()
_item.get_tree().current_scene.add_child(result_instance)
result_instance.global_transform = base_transform
# Free the base item and consume the incoming item.
snap_zone.drop_object()
_item.queue_free()
if other_body.has_method("drop_and_free"):
other_body.drop_and_free()
else:
other_body.queue_free()
# Snap the result into the now-empty zone.
snap_zone.pick_up_object(result_instance)
# Find a CombinableItem among the direct children of [param node].
func _find_combinable(node: Node) -> CombinableItem:
if not node:
return null
for child in node.get_children():
if child is CombinableItem:
return child
return null
-1
View File
@@ -1 +0,0 @@
uid://e3im02nq5cye
+206
View File
@@ -0,0 +1,206 @@
class_name RecipeManager
extends Node
static var _recipes: Dictionary = {}
static var _combining_map: Dictionary = {}
static var _scene_paths: Dictionary = {}
static var _loaded: bool = false
static func load_recipes() -> void:
if _loaded:
return
var recipe_path := "res://recipes.yaml"
if not FileAccess.file_exists(recipe_path):
push_error("RecipeManager: recipes.yaml was not found at %s" % recipe_path)
return
var yaml_available := ClassDB.class_exists("YAML")
if yaml_available:
var result = ClassDB.class_call_static("YAML", "load_file", recipe_path)
if result != null and not result.has_error():
var data = result.get_data()
if typeof(data) == TYPE_DICTIONARY:
_recipes = data
_build_scene_paths()
_build_combining_map()
_loaded = true
return
push_warning("RecipeManager: YAML addon parsed recipes.yaml but returned an unsupported structure; falling back to built-in parser")
else:
push_warning("RecipeManager: YAML addon failed to read %s; falling back to built-in parser: %s" % [recipe_path, result.get_error() if result != null else "unknown error"])
var fallback_data = _load_recipes_with_fallback_parser(recipe_path)
if typeof(fallback_data) != TYPE_DICTIONARY:
push_error("RecipeManager: failed to parse recipes.yaml with both YAML addon and built-in fallback")
return
_recipes = fallback_data
_build_scene_paths()
_build_combining_map()
_loaded = true
static func get_combination(first_id: StringName, second_id: StringName) -> PackedScene:
load_recipes()
var result_id = _combining_map.get(_make_pair_key(first_id, second_id))
if result_id == null:
return null
return get_item_scene(result_id)
static func get_item_scene(item_id: StringName) -> PackedScene:
load_recipes()
var scene_path = _scene_paths.get(str(item_id), "")
if typeof(scene_path) != TYPE_STRING or scene_path.is_empty():
push_error("RecipeManager: no scene mapping found for item id '%s'" % item_id)
return null
var scene = ResourceLoader.load(scene_path)
if not scene:
push_error("RecipeManager: failed to load scene '%s' for item '%s'" % [scene_path, item_id])
return null
return scene as PackedScene
static func print_all_recipes() -> void:
load_recipes()
if not _loaded:
print("RecipeManager: could not load recipes.yaml; no recipes printed.")
return
print("RecipeManager: Loaded recipes")
var combining = _recipes.get("combining", {})
if typeof(combining) != TYPE_DICTIONARY:
print("RecipeManager: combining recipes are not available in recipes.yaml")
return
if combining.is_empty():
print("RecipeManager: no combining recipes found")
return
for result_id in combining.keys():
var recipe_entries = _get_recipe_entries(combining[result_id])
if recipe_entries.is_empty():
print("RecipeManager: invalid combining recipe for '%s'" % result_id)
continue
for recipe in recipe_entries:
if typeof(recipe) == TYPE_ARRAY and recipe.size() == 2:
var first = str(recipe[0])
var second = str(recipe[1])
print("%s + %s -> %s" % [first, second, result_id])
else:
print("RecipeManager: invalid combining recipe for '%s'" % result_id)
static func _load_recipes_with_fallback_parser(recipe_path: String) -> Dictionary:
var file := FileAccess.open(recipe_path, FileAccess.READ)
if file == null:
push_error("RecipeManager: could not open %s: %s" % [recipe_path, FileAccess.get_open_error()])
return {}
var lines := file.get_as_text().split("\n")
var result := {
"items": {},
"combining": {}
}
var current_section := ""
var current_result_id := ""
var current_item_id := ""
var current_item_def: Dictionary = {}
for line in lines:
var trimmed := line.strip_edges()
if trimmed.is_empty() or trimmed.begins_with("#"):
continue
var indent := line.length() - line.lstrip(" \t").length()
if indent == 0:
current_section = ""
current_result_id = ""
current_item_id = ""
if trimmed == "items:":
current_section = "items"
elif trimmed == "combining:":
current_section = "combining"
continue
if current_section == "items":
if indent == 2 and trimmed.ends_with(":"):
current_item_id = trimmed.trim_suffix(":")
current_item_def = {}
result["items"][current_item_id] = current_item_def
continue
if indent >= 4 and not current_item_id.is_empty():
var parts := trimmed.split(":", true, 1)
if parts.size() == 2:
var key := parts[0].strip_edges()
var value := parts[1].strip_edges()
current_item_def[key] = value
continue
if current_section == "combining":
if indent == 2 and trimmed.ends_with(":"):
current_result_id = trimmed.trim_suffix(":")
result["combining"][current_result_id] = []
continue
if trimmed.begins_with("- ") and not current_result_id.is_empty():
var item_text := trimmed.substr(2).strip_edges()
if item_text.begins_with("[") and item_text.ends_with("]"):
var inner := item_text.substr(1, item_text.length() - 2).strip_edges()
var parts := inner.split(",", false)
var recipe := []
for part in parts:
recipe.append(part.strip_edges())
result["combining"][current_result_id].append(recipe)
else:
result["combining"][current_result_id].append([item_text])
return result
static func _build_scene_paths() -> void:
_scene_paths.clear()
var items = _recipes.get("items", {})
if typeof(items) != TYPE_DICTIONARY:
return
for item_id in items.keys():
var item_def = items[item_id]
if typeof(item_def) != TYPE_DICTIONARY:
continue
var scene_path = item_def.get("scene", "")
if typeof(scene_path) == TYPE_STRING and not scene_path.is_empty():
_scene_paths[str(item_id)] = scene_path
static func _get_recipe_entries(recipe_value: Variant) -> Array:
var recipes: Array = []
if typeof(recipe_value) != TYPE_ARRAY:
return recipes
if recipe_value.is_empty():
return recipes
if typeof(recipe_value[0]) == TYPE_ARRAY:
for recipe in recipe_value:
if typeof(recipe) == TYPE_ARRAY and recipe.size() == 2:
recipes.append(recipe)
else:
if recipe_value.size() == 2:
recipes.append(recipe_value)
return recipes
static func _build_combining_map() -> void:
_combining_map.clear()
var combining = _recipes.get("combining", {})
for result_id in combining.keys():
var recipe_entries = _get_recipe_entries(combining[result_id])
if recipe_entries.is_empty():
push_error("RecipeManager: combining recipe '%s' must contain at least one valid recipe" % result_id)
continue
for recipe in recipe_entries:
var first = StringName(recipe[0])
var second = StringName(recipe[1])
var key = _make_pair_key(first, second)
_combining_map[key] = result_id
static func _make_pair_key(first_id: StringName, second_id: StringName) -> StringName:
if str(first_id) <= str(second_id):
return StringName(str(first_id) + "|" + str(second_id))
return StringName(str(second_id) + "|" + str(first_id))
+1
View File
@@ -0,0 +1 @@
uid://cuo88u56uqwuy
+68
View File
@@ -0,0 +1,68 @@
# RecipeManager
## Overview
`RecipeManager` is a static Godot class that centralizes recipe data for food item combinations and allows runtime lookup of result scenes.
The manager loads a single YAML file at startup: `res://recipes.yaml`, using the installed `addons/yaml` addon.
## Responsibilities
- Load recipe definitions from `recipes.yaml`.
- Provide symmetric lookups for two-item combining recipes so `A + B` and `B + A` map to the same result.
- Resolve item IDs to their corresponding scene resources.
- Print all loaded combining recipes when the application starts.
## Data structure in `recipes.yaml`
The recipe YAML file contains two top-level sections:
- `items`: maps item IDs to their scene path and optional metadata.
- `combining`: maps result item IDs to one or more recipe pairs.
Example structure:
```yaml
items:
hamburger:
scene: res://Items/hamburger.tscn
type: meal
burger_buns:
scene: res://Items/BurgerBuns.tscn
type: ingredient
combining:
hamburger:
- [cooked_burger, burger_buns]
- [charcoal, charcoal]
charcoal:
- [cube, cube]
```
### `items`
Each entry under `items` uses the item ID as the key.
The manager reads the `scene` field for each item and uses it to resolve the packed scene for recipe results.
### `combining`
Each entry under `combining` represents a result item ID, and its value is a list of recipe pairs.
Each recipe pair is a two-item array of ingredient IDs.
This allows a single result item to have multiple valid recipes.
The manager normalizes each ingredient pair using a sorted key string internally, so lookups are symmetric.
## Runtime behavior
- `FoodItem.id` is used when combining items to look up the recipe result.
- `RecipeManager.get_combination(first_id, second_id)` computes a canonical key for the pair and returns the resulting item's scene.
- On startup, `main.gd` calls `RecipeManager.print_all_recipes()`, which logs each combining recipe in the form:
`ingredient_a + ingredient_b -> result_id`
## Notes
- `RecipeManager` prefers the installed YAML addon to parse `recipes.yaml` when it is available.
- If the addon is unavailable in the current runtime, the manager falls back to a lightweight built-in parser for the simple `items`/`combining` file format.
- Scene paths now live in the YAML data instead of being hardcoded in the manager.
- Because `RecipeManager` is static, it can be used from any script without creating an instance.
+88 -13
View File
@@ -13,7 +13,8 @@
[ext_resource type="PackedScene" uid="uid://dpot5qie20vf6" path="res://Items/burger.tscn" id="11_qn1ku"]
[ext_resource type="PackedScene" uid="uid://b3m2ag8g5rj4r" path="res://Items/hamburger.tscn" id="12_hfwnn"]
[ext_resource type="PackedScene" uid="uid://dvrk268s7gkxh" path="res://Stations/sink.tscn" id="13_o1b3t"]
[ext_resource type="PackedScene" uid="uid://cnjwtnhwh0i8q" path="res://Scenes/dirt_station.tscn" id="14_irf4n"]
[ext_resource type="PackedScene" uid="uid://cnjwtnhwh0i8q" path="res://Stations/dirt_station.tscn" id="14_irf4n"]
[ext_resource type="PackedScene" uid="uid://cfc4ho67u4r5e" path="res://Items/cooked_burger.tscn" id="15_ctden"]
[sub_resource type="BoxShape3D" id="BoxShape3D_vlqg6"]
size = Vector3(5, 0.1, 5)
@@ -98,40 +99,40 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.832131, 0.40028095, -1.481
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.79203784, 1.6058162, -1.4441451)
[node name="burger" parent="." unique_id=1417604760 instance=ExtResource("11_qn1ku")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.1439226, 1.4195822, -1.2004558)
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.6888188, 1.4195822, -1.7110313)
[node name="burger2" parent="." unique_id=1584460510 instance=ExtResource("11_qn1ku")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.1396115, 1.5300478, -1.2016745)
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.6931299, 1.5300478, -1.71225)
[node name="burger3" parent="." unique_id=1884095916 instance=ExtResource("11_qn1ku")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.1424646, 1.4969791, -1.210453)
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.69027674, 1.4969791, -1.7210286)
[node name="burger4" parent="." unique_id=1844818864 instance=ExtResource("11_qn1ku")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.1414003, 1.4543622, -1.210453)
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.69134104, 1.4543622, -1.7210286)
[node name="Hamburger" parent="." unique_id=761091445 instance=ExtResource("12_hfwnn")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.19655037, 1.623975, -1.4350286)
[node name="PickableObject" parent="." unique_id=1675596942 instance=ExtResource("6_eoxxy")]
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, 0, -2.6077032e-08, 1.4901161e-08, 1, -0.042440414, 1.4792972, -1.0473135)
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, 0, -2.6077032e-08, 1.4901161e-08, 1, 0.6225724, 1.4792972, -1.0473135)
[node name="PickableObject2" parent="." unique_id=713087634 instance=ExtResource("6_eoxxy")]
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, -5.293956e-23, -2.6077032e-08, 1.4901161e-08, 1, -0.029038578, 1.4639391, -1.1673055)
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, -5.293956e-23, -2.6077032e-08, 1.4901161e-08, 1, 0.6359743, 1.4639391, -1.1673055)
[node name="PickableObject3" parent="." unique_id=879935619 instance=ExtResource("6_eoxxy")]
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, 0, -2.6077032e-08, 1.4901161e-08, 1, -0.15074077, 1.4792972, -1.0473135)
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, 0, -2.6077032e-08, 1.4901161e-08, 1, 0.5142721, 1.4792972, -1.0473135)
[node name="PickableObject4" parent="." unique_id=198541902 instance=ExtResource("6_eoxxy")]
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, -5.293956e-23, -2.6077032e-08, 1.4901161e-08, 1, -0.13733894, 1.4639391, -1.1673055)
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, -5.293956e-23, -2.6077032e-08, 1.4901161e-08, 1, 0.5276739, 1.4639391, -1.1673055)
[node name="Hamburger2" parent="." unique_id=1349934579 instance=ExtResource("12_hfwnn")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.15603885, 1.5329368, -1.6386203)
[node name="PickableObject5" parent="." unique_id=1021333509 instance=ExtResource("6_eoxxy")]
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, 0, -2.6077032e-08, 1.4901161e-08, 1, 0.06786254, 1.4792972, -1.0473135)
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, 0, -2.6077032e-08, 1.4901161e-08, 1, 0.73287535, 1.4792972, -1.0473135)
[node name="PickableObject6" parent="." unique_id=1268843669 instance=ExtResource("6_eoxxy")]
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, -5.293956e-23, -2.6077032e-08, 1.4901161e-08, 1, 0.08126438, 1.4639391, -1.1673055)
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, -5.293956e-23, -2.6077032e-08, 1.4901161e-08, 1, 0.7462772, 1.4639391, -1.1673055)
[node name="Sink" parent="." unique_id=2055277359 instance=ExtResource("13_o1b3t")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.3140475, 0.9061539, -1.4941733)
@@ -142,5 +143,79 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 2.0864775, 0.8981018, -1.2449
[node name="Plate2" parent="." unique_id=356790445 instance=ExtResource("10_ay2w6")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.17896795, 1.499024, -1.7930261)
[node name="Plate3" parent="." unique_id=1550985497 instance=ExtResource("10_ay2w6")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.4482152, 1.6058162, -1.1127436)
[node name="CookedBurger" parent="." unique_id=1127807542 instance=ExtResource("15_ctden")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.30671906, 1.4131018, -1.0928738)
[node name="CookedBurger2" parent="." unique_id=160088590 instance=ExtResource("15_ctden")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.30671906, 1.4496142, -1.0928738)
[node name="CookedBurger3" parent="." unique_id=992183367 instance=ExtResource("15_ctden")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.3303163, 1.4398065, -0.5783213)
[node name="CookedBurger4" parent="." unique_id=1837607086 instance=ExtResource("15_ctden")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.30671906, 1.525444, -1.0928738)
[node name="Counter2" type="StaticBody3D" parent="." unique_id=806230637 groups=["station"]]
transform = Transform3D(-4.371139e-08, 0, 1, 0, 1, 0, -1, 0, -4.371139e-08, -1.7617489, 0.8981018, -0.45975232)
[node name="CollisionShape3D" type="CollisionShape3D" parent="Counter2" unique_id=538423775]
shape = SubResource("BoxShape3D_24d3s")
[node name="MeshInstance3D" type="MeshInstance3D" parent="Counter2/CollisionShape3D" unique_id=361943896]
mesh = SubResource("BoxMesh_i5j2u")
[node name="Label3D" type="Label3D" parent="Counter2/CollisionShape3D" unique_id=1988050169]
transform = Transform3D(0.9999992, 0, 0, 0, 0.99999946, 0, 0, 0, 0.9999992, 0.002797456, 0.6309581, -0.2812457)
text = "Counter"
[node name="XRToolsSnapZone" type="Area3D" parent="Counter2" unique_id=1386604336 groups=["station"]]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5270121, 0)
collision_layer = 65536
collision_mask = 65536
script = ExtResource("5_57ppd")
snap_mode = 1
metadata/_custom_type_script = "uid://cquqe4f1m1sw6"
[node name="CollisionShape3D2" type="CollisionShape3D" parent="Counter2/XRToolsSnapZone" unique_id=1704471901]
transform = Transform3D(0.5, 0, 0, 0, 0.5, 0, 0, 0, 0.5, 0, -0.000975132, 0)
shape = SubResource("SphereShape3D_dlkho")
[node name="BurgerBuns2" parent="." unique_id=1210358958 instance=ExtResource("8_rraok")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.3984022, 1.4110342, -0.2721244)
[node name="Counter3" type="StaticBody3D" parent="." unique_id=1980665158 groups=["station"]]
transform = Transform3D(-4.371139e-08, 0, 1, 0, 1, 0, -1, 0, -4.371139e-08, -1.7617489, 0.8981018, 0.5719713)
[node name="CollisionShape3D" type="CollisionShape3D" parent="Counter3" unique_id=67427622]
shape = SubResource("BoxShape3D_24d3s")
[node name="MeshInstance3D" type="MeshInstance3D" parent="Counter3/CollisionShape3D" unique_id=267368942]
mesh = SubResource("BoxMesh_i5j2u")
[node name="Label3D" type="Label3D" parent="Counter3/CollisionShape3D" unique_id=19543496]
transform = Transform3D(0.9999992, 0, 0, 0, 0.99999946, 0, 0, 0, 0.9999992, 0.002797456, 0.6309581, -0.2812457)
text = "Counter"
[node name="XRToolsSnapZone" type="Area3D" parent="Counter3" unique_id=1296955986 groups=["station"]]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5270121, 0)
collision_layer = 65536
collision_mask = 65536
script = ExtResource("5_57ppd")
snap_mode = 1
metadata/_custom_type_script = "uid://cquqe4f1m1sw6"
[node name="CollisionShape3D2" type="CollisionShape3D" parent="Counter3/XRToolsSnapZone" unique_id=1150602999]
transform = Transform3D(0.5, 0, 0, 0, 0.5, 0, 0, 0, 0.5, 0, -0.000975132, 0)
shape = SubResource("SphereShape3D_dlkho")
[node name="PickableObject7" parent="." unique_id=641653545 instance=ExtResource("6_eoxxy")]
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, 0, -2.6077032e-08, 1.4901161e-08, 1, -1.3556751, 1.4792972, 0.28881657)
[node name="PickableObject8" parent="." unique_id=1733819363 instance=ExtResource("6_eoxxy")]
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, -5.293956e-23, -2.6077032e-08, 1.4901161e-08, 1, -1.3422732, 1.4639391, 0.16882455)
[node name="PickableObject9" parent="." unique_id=12419746 instance=ExtResource("6_eoxxy")]
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, 0, -2.6077032e-08, 1.4901161e-08, 1, -1.4639754, 1.4792972, 0.28881657)
[node name="PickableObject10" parent="." unique_id=313160217 instance=ExtResource("6_eoxxy")]
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, -5.293956e-23, -2.6077032e-08, 1.4901161e-08, 1, -1.4505737, 1.4639391, 0.16882455)
@@ -1,6 +1,6 @@
[gd_scene format=3 uid="uid://cnjwtnhwh0i8q"]
[ext_resource type="Script" uid="uid://q7tpwmrbmu1m" path="res://Scenes/dirt_station.gd" id="1_hc1d4"]
[ext_resource type="Script" uid="uid://q7tpwmrbmu1m" path="res://Stations/dirt_station.gd" id="1_hc1d4"]
[ext_resource type="Script" uid="uid://cquqe4f1m1sw6" path="res://addons/godot-xr-tools/objects/snap_zone.gd" id="2_v0ytd"]
[sub_resource type="BoxShape3D" id="BoxShape3D_24d3s"]
+323
View File
@@ -0,0 +1,323 @@
# Godot YAML Changelog
## Version 2.2.0
- Updated GDSchema to version 2.0.0, with JSON schema draft 2020-12 validation spec
- Enable CTRL+mouse wheel to zoom in the YAML editor ([Issue #17](https://github.com/fimbul-works/godot-yaml/issues/17))
- Renamed HistoryState class to YAMLEditorHistoryState, fixing [Issue #19](https://github.com/fimbul-works/godot-yaml/issues/19)
- Various small fixes
## Version 2.1.3
- Changed the behavior of the `x-yaml-tag` Schema validation rule to ignore tagless nodes
## Version 2.1.2
- Added support for Android (arm64, x86_64) platforms thanks to @Nemo1166
- Rewrote `String` variant strigification rules, resolving [Issue #15](https://github.com/fimbul-works/godot-yaml/issues/15)
- Fixed application hanging when closed due to an issue with the YAML class registry
## Version 2.1.1
Empty strings are stringified with quotes.
## Version 2.1.0
When passing the custom tag to `YAML.schema_register(class, serialize_method, deserialize_static, custom_tag)` use the custom tag when emitting, resolving [Issue #13](https://github.com/fimbul-works/godot-yaml/issues/13).
## Version 2.0.0
The second major version of Godot YAML brings powerful schema validation capabilities through the integration of [GDSchema](https://github.com/fimbul-works/gdschema), enabling [JSON Schema Draft-7](https://json-schema.org/) validation for your YAML data. This release also includes improved multi-document handling, custom tag support, and several important bug fixes. See the [migration guide](#migration-guide-from-version-1-to-200) for breaking changes.
### Major Features
#### Schema Validation (New!)
Version 2.0.0 introduces comprehensive schema validation powered by [GDSchema](https://github.com/fimbul-works/gdschema), bringing the full power of [JSON Schema Draft-7](https://json-schema.org/) to your YAML workflows. This flagship feature enables you to:
- **Validate YAML data** against industry-standard JSON Schema specifications
- **Define schemas in YAML** with native YAML syntax for better readability
- **Auto-apply defaults** when properties are missing using the `default` keyword
- **Validate YAML tags** with the custom `x-yaml-tag` keyword for type safety
- **Reference schemas** using `$ref` for modular, reusable validation rules
- **Register schemas globally** with `$id` for cross-file validation
- **Get detailed error reports** with JSON Pointer paths and constraint information
New classes:
- `Schema` - Validates data against JSON Schema Draft-7 specifications
- `SchemaValidationResult` - Contains detailed validation results and errors
New YAML methods:
- `YAML.load_schema_from_string()` - Parse YAML schema definitions
- `YAML.load_schema_from_file()` - Load schema files with auto-registration
- `YAML.parse_and_validate()` - Parse and validate YAML in one step
Schema features include:
- Full JSON Schema Draft-7 support (type checking, numeric constraints, string patterns, array/object validation)
- Logical composition (`allOf`, `anyOf`, `oneOf`, `not`)
- Conditional validation (`if`/`then`/`else`)
- Schema definitions and references (`$defs`, `$ref`)
- Custom format validators
- Thread-safe lazy compilation with caching
- YAML-specific extensions:
- `default` - Provides default values for missing properties
- `x-yaml-tag` - Validates YAML type tags (e.g., `!Resource`, `!CustomType`)
Example:
```gdscript
# Define a schema in YAML
var schema_yaml = """
$id: "http://example.com/user.yaml"
type: object
properties:
username:
type: string
minLength: 3
email:
type: string
format: email
role:
type: string
default: user
x-yaml-tag: UserRole
required:
- username
- email
"""
# Build the schema (auto-registered with $id)
var schema = YAML.load_schema_from_string(schema_yaml)
# Validate data
var data_yaml = """
username: Alice
email: alice@exampe.com
"""
var result = YAML.parse_and_validate(data_yaml, schema)
if result.is_valid():
print(result.get_data().role) # "user" (default applied)
else:
print(result.get_summary()) # Detailed error report
```
### Other Changes
- **Breaking:** Removed the `YAMLResult.get_data(index)` parameter. Use `YAMLResult.get_document(index)` instead for multi-document access.
- Added `YAMLResult.get_documents()` and `YAMLResult.has_multiple_documents()` methods for easier multi-document YAML handling
- Added support for custom tags when registering classes with `YAML.register_class()` - now you can specify custom YAML tags like `!Item` or `!ruby:object/Item`
- Added `YAMLCodeEdit` class to enable embedding the YAML editor directly into your projects
- Added missing example for handling multi-document YAML files
- Upgraded [RapidYAML](https://github.com/biojppm/rapidyaml) to version 0.10.0
### Bug Fixes
- Fixed `YAMLResult.get_data()` only returning the first element of a document that only contains an array
- Fixed invalid numeric value parsing that could cause crashes or incorrect values
- Fixed empty strings being parsed and emitted as null values instead of empty strings
- Fixed YAML files not opening correctly in Godot 4.3 editor
### Migration Guide From Version 1.* to 2.0.0
Replace `YAMLResult.get_data(index)` calls with `YAMLResult.get_document(index)`:
**Old version:**
```gdscript
var result = YAML.parse(multi_doc_yaml)
var first_doc = result.get_data(0)
var second_doc = result.get_data(1)
```
**New version:**
```gdscript
var result = YAML.parse(multi_doc_yaml)
var first_doc = result.get_document(0)
var second_doc = result.get_document(1)
# Or get all documents at once
var all_docs = result.get_documents()
```
## Version 1.1.0
- YAML files can now be loaded with the `!Resource` tag, allowing modular composition
- Fixes:
- Force YAML indentation to always use spaces instead of tabs
- The file system should now update properly when saving a new YAML file
- Fixed tab indentation not working correctly in the YAML editor
- Fixed Packed Array types not detecting array templates
- Prevent duplicates in YAML editor file list
## Version 1.0.0
The first major version of Godot YAML includes many improvements, and some breaking changes. See the [migration guide](#migration-guide-from-version-0121-to-100) for details.
### Changes
- Streamlined API in many places
- Added class documentation for the in-editor help
- Added custom YAML editor inside the Godot engine editor
- Added `YAMLSecurity` class for safer resource loading during parsing
- `YAMLStyle` API changes:
- Adopted Godot terminology for container form (Array and Dictionary instead of Sequence and Map)
- Merged scalar and quote styles into unified string style
- Separated number format into integer and float formats respectively
- Removed binary encoding (now always uses base64 encoding)
- Implemented serializing/deserializing style definitions
- Implemented stringifying non-local-to-scene resource references
- Performance optimizations
- Improved error reporting across the board with line/column tracking when parsing
- Fixes:
- Style definitions should now be parsed correctly
- Stringifying with styles should now work consistently
- Fixed custom tags not being used when stringifying with styles
- Fixed integer to string conversion with 64 bit values
### Migration Guide From Version 0.12.1 to 1.0.0
This migration guide should help you update your code to work with the latest version of the YAML plugin. The core functionality and architecture remain similar, but the style system has been refined for better consistency and usability.
#### Custom Class Serialization and Deserialization
Custom class serialization has been changed to allow serializing to/from just dictionaries to any of the supported Godot variants:
**Old version:**
```gdscript
class_name CustomClass extends Resource
@export var name: String
func _init(p_name: String = "") -> void:
name = p_name
func to_dict() -> Dictionary:
return {
"name": name
}
static func from_dict(data: Dictionary) -> CustomClass:
return CustomClass.new(data["name"])
```
**New version:**
```gdscript
class_name CustomClass extends Resource
@export var name: String
func _init(p_name: String = "") -> void:
name = p_name
func serialize() -> Variant:
return {
"name": name
}
static func deserialize(data: Variant) -> Variant:
if typeof(data) != TYPE_DICTIONARY:
return YAMLResult.error("CustomClass expects Dictionary")
return CustomClass.new(data["name"])
```
#### Container Form Changes
Previously the container forms used the YAML standard naming of *Sequences* and *Maps*, but to make the extension more Godot friendly the containers now follow Godot's naming conventions of *Arrays* and *Dictionaries*:
```gdscript
# Specify how containers are formatted
style.set_container_form(YAMLStyle.FORM_ARRAY) # Previously FORM_SEQ
style.set_container_form(YAMLStyle.FORM_DICTIONARY) # Previously FORM_MAP
```
#### Scalar Style and Quote Style Merged
**Old version:**
```gdscript
var style = YAML.create_style()
# Separate methods for different aspects of string formatting
style.set_scalar_style(YAMLStyle.SCALAR_LITERAL) # For block style (|)
style.set_quote_style(YAMLStyle.QUOTE_DOUBLE) # For quoting style
```
**New version:**
```gdscript
var style = YAML.create_style()
# Single method combining both formatting aspects
style.set_string_style(YAMLStyle.STRING_LITERAL) # For block style (|)
style.set_string_style(YAMLStyle.STRING_QUOTE_DOUBLE) # For quoting style
```
The constants have been unified:
- `YAMLStyle.SCALAR_PLAIN`, `YAMLStyle.SCALAR_LITERAL`, `YAMLStyle.SCALAR_FOLDED` are now accessible through the `STRING_*` constants
- `YAMLStyle.QUOTE_NONE`, `YAMLStyle.QUOTE_SINGLE`, `YAMLStyle.QUOTE_DOUBLE` have been replaced with `STRING_PLAIN`, `STRING_QUOTE_SINGLE`, `STRING_QUOTE_DOUBLE`
#### Number Format Changes
**Old version:**
```gdscript
style.set_number_format(YAMLStyle.NUM_HEX)
```
**New version:**
```gdscript
style.set_integer_format(YAMLStyle.INT_HEX)
style.set_float_format(YAMLStyle.FLOAT_SCIENTIFIC)
```
Integer and float formatting have been split into two methods with dedicated enum values:
- `YAMLStyle.NUM_DECIMAL`, `YAMLStyle.NUM_HEX`, etc. are now `YAMLStyle.INT_DECIMAL`, `YAMLStyle.INT_HEX`, etc.
- `YAMLStyle.NUM_SCIENTIFIC` is now `YAMLStyle.FLOAT_SCIENTIFIC`
#### Removed YAMLLoader and YAMLWriter Classes
If using the old loaders, they have been integrated to the main `YAML` class:
**Old version:**
```gdscript
# Load YAML from a file
var data = YAMLLoader.load_file("res://data.yaml")
if YAMLLoader.last_error != null:
print("Error loading file: ", YAMLLoader.last_error)
else:
print("Loaded data: ", data)
# Save data to a YAML file
var data = {"key": "value", "list": [1, 2, 3]}
var success = YAMLWriter.save_file(data, "user://output.yaml")
if !success:
print("Error saving file: ", YAMLWriter.last_error)
```
**New version:**
```gdscript
# Load YAML from a file
var load_result = YAML.load_file("res://example.yaml")
if load_result.has_error():
push_error(load_result.get_error())
return null
var data = load_result.get_data()
print("Loaded data: ", data)
# Save data to a YAML file
var save_result = YAML.save_file(data, "user://output.yaml")
if save_result.has_error():
push_error(save_result.get_error())
return null
var yaml_text = save_result.get_data()
print("Saved YAML:\n", yaml_text)
# The above can also be written as
var null_on_fail = YAML.try_load_file("res://example.yaml")
var success = YAML.try_save_file(data, "user://output.yaml")
```
## Older Versions
- **0.12.1** - Build support for Linux (x86 64-bit)
- **0.12.0** - Performance optimizations, bug fixes, and comprehensive tests for all variant types
- **0.11.0** - Added support for parsing multiple documents, and error handling for custom class deserialization
- **0.10.1** - Fixed issue with custom Resources not being serializable
- **0.10.0** - Added custom class serialization support, upgraded to Godot 4.3
- **0.9.0** - Initial public release
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 FimbulWorks
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+767
View File
@@ -0,0 +1,767 @@
# Godot YAML
A high-performance YAML parsing and serialization plugin for Godot 4.3, powered by [RapidYAML](https://github.com/biojppm/rapidyaml). This plugin offers comprehensive YAML support with customizable styling options, full Godot variant type handling, custom class serialization, and industry-standard schema validation.
**New to YAML in Godot?** Check out the [`examples/`](./addons/yaml/examples/) directory for comprehensive usage examples covering all features.
## Version History
- **2.2.0** (Current) - Upgraded GDSchema to 2.0.0 for JSON validation draft 2020-12 spec, and various bug fixes
- **2.1.2** - Added support for Android (arm64, x86_64) platforms thanks to @Nemo1166, and fixed some bugs
- **2.1.1** - Empty strings are stringified with quotes
- **2.1.0** - When passing custom tag to `YAML.schema_register(class, serialize_method, deserialize_static, custom_tag)` use the custom tag when stringifying
- **2.0.0** - Major release with schema validation powered by [GDSchema](https://github.com/fimbul-works/gdschema), improved multi-document handling, and bug fixes.
See [the full changelog](./CHANGELOG.md) for more details.
## Features
-**High Performance**: Built on the lightweight and efficient [RapidYAML](https://github.com/biojppm/rapidyaml) library
- 🧩 **Comprehensive Variant Support**: Handles all Godot built-in Variant types (except Callable and RID)
-**Schema Validation**: Full JSON Schema Draft 2020-12 validation powered by [GDSchema](https://github.com/fimbul-works/gdschema) with YAML-specific extensions
- 🧪 **Custom Class Serialization**: Register your GDScript classes for seamless serialization and deserialization
- 📄 **Multi-Document Support**: Parse YAML files with multiple `---` separated documents
- 🎨 **Style Customization**: Control how YAML is formatted with customizable style options
- 📝 **Comprehensive Error Handling**: Detailed error reporting with line and column information
- 🔀 **Thread-Safe**: Fully supports multi-threaded parsing and emission
- 🗂️ **Resource References**: Use `!Resource` tags to reference and load external resources
- 🛡️ **Security Controls**: Manage resource loading security during YAML parsing
## Compatibility
- Requires **Godot 4.3** or higher
- Supported platforms:
- Windows (x86 64-bit)
- Linux (x86 64-bit)
- macOS: (Universal)
- **Note**: Some macOS configurations (particularly newer versions with stricter Gatekeeper policies) may prevent loading of GDExtensions generally, not just this plugin. If the extension fails to load, try building from source or test with other GDExtensions to determine if this is a system-wide issue.
- Android (x86 64-bit, ARM 64-bit)
## Basic Usage
### Parsing YAML
```gdscript
# Parse a YAML string
var yaml_text = """
player:
name: Knight
health: 100
inventory:
- Sword
- Shield
- Health Potion
"""
var result = YAML.parse(yaml_text)
if result.has_error():
push_error("Parse error: %s" % result.get_error())
return
var data = result.get_data()
print("Player name: %s" % data.player.name)
print("Health: %d" % data.player.health)
print("First item: %s" % data.player.inventory[0])
```
### Converting Data to YAML
```gdscript
# Convert Godot data to YAML
var enemy_data = {
"name": "Dragon",
"health": 500,
"attacks": ["Bite", "Fire Breath", "Tail Whip"]
}
var string_result = YAML.stringify(enemy_data)
if !string_result.has_error():
print(string_result.get_data())
```
### Working with Files
```gdscript
# Load YAML from a file
var result = YAML.load_file("res://data/level_data.yaml")
if result.has_error():
push_error("YAML parsing failed: " + result.get_error())
return
# Success - get the data and use it
var level_data = result.get_data()
print("Loaded level: " + level_data.name)
# Save data to a YAML file
var save_data = {
"player": {
"name": "Hero",
"level": 10,
"position": [25, 48]
},
"quests_completed": ["Rats in the Cellar", "Lost Artifact"]
}
var save_result = YAML.save_file(save_data, "user://save_game.yaml")
if !save_result.has_error():
print("Game saved successfully!")
else:
push_error("Save failed: " + save_result.get_error())
```
### Simplified API
The extension provides simplified methods that return direct results rather than YAMLResult objects:
```gdscript
# Quick parsing without error checking
var data = YAML.try_parse("""
weapon: Axe
damage: 25
""")
# Or YAML.try_load_file
if data:
print("Weapon: %s (Damage: %d)" % [data.weapon, data.damage])
else:
print("Failed to parse weapon data")
# Quick stringify
var npc = {
"name": "Merchant",
"dialog": "Welcome to my shop!",
"shop_items": ["Potion", "Map", "Torch"]
}
var yaml_text = YAML.try_stringify(npc)
# Or YAML.try_save_file
if yaml_text:
save_to_file(yaml_text)
```
## Schema Validation
Version 2.0.0 introduces powerful schema validation capabilities through the integration of [GDSchema](https://github.com/fimbul-works/gdschema). Define your data structures using industry-standard JSON Schema Draft-7 syntax, enhanced with YAML-specific features for an optimal validation experience.
### Why Use Schema Validation?
Schema validation ensures your YAML data meets specific requirements before your game uses it. This is invaluable for:
- **Configuration files**: Validate game settings, difficulty parameters, and preferences
- **User-generated content**: Ensure mod data and custom levels follow your specifications
- **Save files**: Verify save data integrity with automatic default values
- **Data interchange**: Validate API responses and external data sources
- **Development**: Catch data errors early with detailed validation reports
### Quick Start
The `YAML.parse_and_validate()` method combines parsing and validation in one step, returning a `YAMLResult` that may contain both parse errors and validation errors:
```gdscript
# Define a schema in YAML (more readable than JSON!)
var schema_yaml = """
type: object
properties:
username:
type: string
minLength: 3
maxLength: 20
email:
type: string
format: email
level:
type: integer
minimum: 1
default: 1
required:
- username
- email
"""
# Build the schema
var schema = YAML.load_schema_from_string(schema_yaml)
if not schema:
push_error("Failed to parse schema")
return
# Parse and validate YAML data
var player_yaml = """
username: hero
email: hero@example.com
"""
var result = YAML.parse_and_validate(player_yaml, schema)
# Check for parse errors first
if result.has_error():
push_error("Parse error: %s" % result.get_error())
return
# Then check for validation errors
if result.has_validation_errors():
print(result.get_validation_summary()) # Detailed error report
return
# Success - use the validated data with defaults applied
var player_data = result.get_data()
print("Level: %d" % player_data.level) # 1 (default applied!)
```
### YAML-Specific Schema Extensions
Godot YAML includes two powerful extensions to standard JSON Schema:
#### 1. The `default` Keyword
Automatically apply default values when properties are missing:
```gdscript
var schema = YAML.load_schema_from_string("""
type: object
properties:
difficulty:
type: string
enum: [easy, normal, hard]
default: normal
music_volume:
type: number
minimum: 0
maximum: 1
default: 0.8
show_tutorial:
type: boolean
default: true
""")
# Parse empty YAML - defaults are applied during validation!
var result = YAML.parse_and_validate('{}', schema)
if !result.has_validation_errors():
var settings = result.get_data()
print(settings.difficulty) # "normal"
print(settings.music_volume) # 0.8
print(settings.show_tutorial) # true
```
#### 2. The `x-yaml-tag` Keyword
Validate that values have the correct YAML type tag:
```gdscript
var schema = YAML.load_schema_from_string("""
type: object
properties:
player_sprite:
type: string
x-yaml-tag: Resource # Must be tagged with !Resource
custom_item:
type: object
x-yaml-tag: Item # Must be tagged with !Item
""")
# This YAML will validate successfully
var valid_yaml = """
player_sprite: !Resource "res://player.png"
custom_item: !Item
name: Sword
damage: 10
"""
var result = YAML.parse_and_validate(valid_yaml, schema)
if !result.has_validation_errors():
var data = result.get_data()
print("Sprite loaded: %s" % (data.player_sprite is Texture2D))
```
### Reusable Schemas with Global Registration
Schemas with an `$id` field are automatically registered globally, enabling modular schema design:
```gdscript
# Define a reusable schema (saved as user_schema.yaml)
var user_schema_yaml = """
$id: "http://mygame.com/schemas/user.yaml"
type: object
properties:
username:
type: string
minLength: 3
email:
type: string
format: email
avatar:
type: string
x-yaml-tag: Resource
required:
- username
- email
"""
# Load and auto-register the schema
var user_schema = YAML.load_schema_from_string(user_schema_yaml)
# Now reference it from other schemas!
var game_data_schema = YAML.load_schema_from_string("""
type: object
properties:
player:
$ref: "http://mygame.com/schemas/user.yaml"
high_score:
type: integer
minimum: 0
""")
# Parse and validate nested YAML data
var game_yaml = """
player:
username: alice
email: alice@example.com
high_score: 1000
"""
var result = YAML.parse_and_validate(game_yaml, game_data_schema)
if result.has_error():
push_error("Parse error: %s" % result.get_error())
elif result.has_validation_errors():
push_error(result.get_validation_summary())
else:
var game_data = result.get_data()
print("Player: %s, Score: %d" % [game_data.player.username, game_data.high_score])
```
### Parse and Validate in One Step
For the most streamlined workflow, use `YAML.parse_and_validate()` which returns a `YAMLResult` with integrated validation information:
```gdscript
# Load a schema file once at startup
var config_schema = YAML.load_schema_from_file("res://schemas/config_schema.yaml")
# Later, parse and validate user config in one call
var user_config = """
graphics:
resolution: 1920x1080
vsync: true
audio:
master_volume: 0.8
"""
var result = YAML.parse_and_validate(user_config, config_schema)
# Check for parse errors
if result.has_error():
push_error("YAML parse error: %s" % result.get_error())
return
# Check for validation errors using YAMLResult methods
if result.has_validation_errors():
# Show detailed error report to user
print("Configuration has %d error(s):" % result.get_validation_error_count())
print(result.get_validation_summary())
# Or iterate through individual errors
for error in result.get_validation_errors():
print(" - %s at %s" % [error.message, error.instance_path])
return
# Both parsing and validation succeeded
var config = result.get_data()
apply_settings(config)
```
### Auto-Discovery with `$schema`
Include a `$schema` field in your YAML to automatically validate against a registered schema:
```gdscript
# Register your schema once
YAML.load_schema_from_file("res://schemas/save_game.yaml")
# This schema has: $id: "http://mygame.com/schemas/save_game.yaml"
# YAML files can reference the schema directly
var save_data = """
$schema: "http://mygame.com/schemas/save_game.yaml"
player:
name: Hero
level: 10
checkpoint: forest_entrance
"""
# Parse and validate - no need to specify schema!
var result = YAML.parse_and_validate(save_data)
# YAMLResult provides validation checking methods
if result.has_error():
push_error("Parse error: %s" % result.get_error())
elif result.has_validation_errors():
push_error("Validation failed:\n%s" % result.get_validation_summary())
else:
# Safe to use - both parsing and validation succeeded
load_game(result.get_data())
```
### Advanced Schema Features
JSON Schema Draft-7 offers powerful composition and validation features:
```gdscript
var advanced_schema = YAML.load_schema_from_string("""
$defs:
# Reusable definitions
positive_integer:
type: integer
minimum: 1
item_base:
type: object
properties:
name:
type: string
minLength: 1
value:
$ref: "#/$defs/positive_integer"
required: [name, value]
type: object
properties:
inventory:
type: array
items:
# Logical composition - item must match base AND have quantity
allOf:
- $ref: "#/$defs/item_base"
- type: object
properties:
quantity:
$ref: "#/$defs/positive_integer"
equipment:
# Conditional validation
if:
properties:
type:
const: weapon
then:
properties:
damage:
type: integer
minimum: 1
required: [damage]
else:
properties:
defense:
type: integer
minimum: 1
required: [defense]
""")
```
### Detailed Validation Error Reports
When validation fails, `YAMLResult` provides comprehensive error information through dedicated methods:
```gdscript
var schema = YAML.load_schema_from_string("""
type: object
properties:
player:
type: object
properties:
name:
type: string
minLength: 3
age:
type: integer
minimum: 0
email:
type: string
format: email
required: [name, email]
""")
var invalid_yaml = """
player:
name: ab
age: -5
email: not-email
"""
var result = YAML.parse_and_validate(invalid_yaml, schema)
# Check parse error first
if result.has_error():
push_error("YAML syntax error: %s" % result.get_error())
return
# Then check validation with YAMLResult methods
if result.has_validation_errors():
# Get formatted summary for display
print(result.get_validation_summary())
# Schema validation failed with 3 error(s):
# [1] At '/player/name': String length 2 is less than minimum 3 (minLength)
# [2] At '/player/age': Value -5 is less than minimum 0 (minimum)
# [3] At '/player/email': String "not-email" does not match format email (format)
# Get error count
print("\nTotal errors: %d" % result.get_validation_error_count())
# Iterate through individual errors
for error in result.get_validation_errors():
print("\nError details:")
print(" Path: %s" % error.instance_path)
print(" Constraint: %s" % error.keyword)
print(" Message: %s" % error.message)
print(" Invalid value: %s" % error.invalid_value)
# Or use the full SchemaValidationResult for advanced inspection
var validation = result.get_validation_result()
print("\nAll error paths: %s" % validation.get_all_error_paths())
print("Violated constraints: %s" % validation.get_violated_constraints())
```
For more information on JSON Schema features, see the [GDSchema documentation](https://github.com/fimbul-works/gdschema).
### Understanding Parse vs Validation Errors
When using `YAML.parse_and_validate()`, the `YAMLResult` may contain two types of errors:
1. **Parse Errors** (checked with `has_error()`): YAML syntax errors that prevent parsing
2. **Validation Errors** (checked with `has_validation_errors()`): Schema validation failures after successful parsing
Always check parse errors first, as validation only happens if parsing succeeds:
```gdscript
var result = YAML.parse_and_validate(yaml_text, schema)
# Check parse error first
if result.has_error():
print("YAML syntax error at line %d: %s" % [
result.get_error_line(),
result.get_error_message()
])
return
# Then check validation
if result.has_validation_errors():
print("Data is valid YAML but doesn't match schema:")
print(result.get_validation_summary())
return
# Both checks passed - safe to use
var data = result.get_data()
```
## Multi-Document YAML Support
```gdscript
var yaml_text = """
# Player stats
name: Hero
health: 100
---
# Game settings
difficulty: hard
enable_tutorial: false
"""
var result = YAML.parse(yaml_text)
if !result.has_error():
# Check if the result contains multiple documents
print("Has multiple documents: %s" % result.has_multiple_documents())
# Check document count
var doc_count = result.get_document_count()
print("Found %d documents" % doc_count)
# Get the documents
var player_data = result.get_document(0)
var settings = result.get_document(1)
print("Player: %s (Health: %d)" % [player_data.name, player_data.health])
print("Difficulty: %s" % settings.difficulty)
# Get documents as an array
var docs = result.get_documents()
```
## Error Handling
The `YAMLResult` class provides detailed error information:
```gdscript
var result = YAML.parse(user_yaml)
if result.has_error():
push_error("YAML parse error: " + result.get_error())
# Example output: "parse error (line 3, column 5)"
# Get detailed error information
var error_message = result.get_error_message()
var error_line = result.get_error_line()
var error_column = result.get_error_column()
print("Error at line %d, column %d: %s" % [error_line, error_column, error_message])
# Highlight the error position
if error_line > 0 and error_column > 0:
var yaml_lines = yaml_text.split("\n")
var error_line_content = yaml_lines[error_line - 1]
print(error_line_content)
print(" ".repeat(error_column - 1) + "^ Error here")
```
## Custom Class Serialization
You can register your custom GDScript classes for seamless serialization:
```gdscript
# Define a custom class
class_name Item extends Resource
var name: String
var weight: float
var value: int
func _init(p_name = "", p_weight = 0.0, p_value = 0):
name = p_name
weight = p_weight
value = p_value
static func deserialize(data):
if typeof(data) != TYPE_DICTIONARY:
return YAMLResult.error("Item requires a dictionary")
return Item.new(
data.get("name", ""),
data.get("weight", 0.0),
data.get("value", 0)
)
func serialize():
return {
"name": name,
"weight": weight,
"value": value
}
# Register the class for YAML serialization
YAML.register_class(Item)
# Now we can serialize/deserialize Item objects
var sword = Item.new("Iron Sword", 5.0, 100)
var result = YAML.stringify(sword)
print(result.get_data())
# Output: !Item {name: Iron Sword, weight: 5.0, value: 100}
```
## Security Controls with YAMLSecurity
The `YAMLSecurity` class helps guard against unsafe loading of untrusted content:
```gdscript
var security = YAML.create_security()
# Only allow textures from the game's asset folder
security.allow_path("res://assets/textures", ["Texture2D"])
# Block scenes for safety
security.block_type("PackedScene")
# Parse YAML with custom security settings
var yaml_text = """
player:
name: Hero
sprite: !Resource 'res://assets/textures/player.png'
"""
var result = YAML.parse(yaml_text, security)
if result.has_error():
push_error(result.get_error())
else:
var data = result.get_data()
print("Player sprite loaded: " + str(data.player.sprite is Texture2D))
```
## Style Customization with YAMLStyle
Control the formatting and appearance of your YAML output:
```gdscript
# Create a new style configuration
var style = YAML.create_style()
# Set global string style to double-quoted
style.set_string_style(YAMLStyle.STRING_QUOTE_DOUBLE)
# Set integers to display in hexadecimal format
style.set_integer_format(YAMLStyle.INT_HEX)
# Define specific style for player inventory items to use flow style
var inventory_style = style.create_child("inventory")
inventory_style.set_flow_style(YAMLStyle.FLOW_SINGLE)
# Create some data to format
var player_data = {
"name": "Hero",
"level": 42,
"inventory": ["Sword", "Shield", "Potion"]
}
# Apply the style when stringifying
var result = YAML.stringify(player_data, style)
print(result.get_data())
# Output will look like:
# name: "Hero"
# level: 0x2A
# inventory: ["Sword", "Shield", "Potion"]
```
### Style Detection and Preservation
You can detect and preserve the style of existing YAML:
```gdscript
# Parse with style detection
var result = YAML.parse(yaml_text, null, true)
if !result.has_error() and result.has_style():
var data = result.get_data()
var style = result.get_style()
# Modify the data but preserve formatting
data.player.health = 200
# Reserialize with the same style
var output = YAML.stringify(data, style)
save_file("user://modified_config.yaml", output.get_data())
```
## Examples
Check out the [`examples/`](./addons/yaml/examples/) directory for comprehensive code samples covering:
- Basic parsing and stringification
- Multi-document YAML handling
- Custom class serialization
- **Schema validation workflows**
- Security configurations
- Style customization
- Error handling patterns
- And more!
## Installation
1. Download the plugin from the Godot Asset Library or from the GitHub repository
2. Extract the contents into your project's `addons/` directory
3. Enable the plugin in Project Settings → Plugins
## License
MIT License - See LICENSE file for details.
---
Built with ⚡ by [FimbulWorks](https://github.com/fimbul-works)
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+14
View File
@@ -0,0 +1,14 @@
class_name YAMLCodeEdit extends CodeEdit
var syntax_highlighter_script = preload("res://addons/yaml/editor/syntax_highlighting/syntax_highlighter.gd")
func _init() -> void:
# YAML indentation
set_indent_size(2)
set_indent_using_spaces(true)
indent_automatic = true
indent_automatic_prefixes = [":"]
# Syntax highlighting
if not syntax_highlighter:
syntax_highlighter = syntax_highlighter_script.new()
+1
View File
@@ -0,0 +1 @@
uid://bex4rlxea675g
+394
View File
@@ -0,0 +1,394 @@
@tool
class_name YAMLCodeEditor extends CodeEdit
signal content_changed
signal save_requested
signal close_requested
signal undo_requested
signal redo_requested
signal validation_requested
signal zoom_changed(zoom_level)
var error_indicators := {}
var snapshot_debounce_timer: Timer
var error_line_color: Color = Color(1.0, 0.3, 0.3, 0.1)
var syntax_highlighter_script = preload("res://addons/yaml/editor/syntax_highlighting/editor_syntax_highlighter.gd")
var suppress_text_changed: bool = false
# Zoom functionality variables
var zoom_level: float = 1.0 # 100%
var default_font_size: int = 14 # Default font size
func _ready() -> void:
# Clear text to reset the editor state
text = ""
# YAML indentation
set_indent_size(2)
set_indent_using_spaces(true)
indent_automatic_prefixes = [":"]
scroll_smooth = true
set_highlight_current_line(true)
# Syntax highlighting
if not syntax_highlighter:
syntax_highlighter = syntax_highlighter_script.new()
# Do not lose selection when focus is lost
deselect_on_focus_loss_enabled = false
set_focus_mode(Control.FOCUS_ALL)
# Create debounce timer for content changes
snapshot_debounce_timer = Timer.new()
add_child(snapshot_debounce_timer)
snapshot_debounce_timer.one_shot = true
snapshot_debounce_timer.wait_time = 0.3 # 300ms
snapshot_debounce_timer.timeout.connect(_on_snapshot_debounce_timeout)
# Connect signals
text_changed.connect(_on_text_changed)
gui_input.connect(_on_gui_input_focus)
# Register YAML code completion
register_yaml_code_completion()
# Apply initial font size
_update_font_size()
func _on_text_changed() -> void:
if suppress_text_changed:
return
# Clear error indicators when text changes
clear_error_indicators()
# Request a snapshot with debounce
snapshot_debounce_timer.start()
func _on_snapshot_debounce_timeout() -> void:
# Emit content changed signal
content_changed.emit()
# Request validation
validation_requested.emit()
func _on_gui_input_focus(event: InputEvent) -> void:
# Grab focus when clicked
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
grab_focus()
func cut_selection() -> void:
if has_selection():
# Cut the selected text to clipboard
DisplayServer.clipboard_set(get_selected_text())
delete_selection()
else:
# If no selection, cut the current line (like default script editor)
var line := get_caret_line()
var line_text := get_line(line)
DisplayServer.clipboard_set(line_text)
# Delete the current line
select(line, 0, line, line_text.length())
delete_selection()
# If this isn't the last line, also remove the line break
if line < get_line_count() - 1:
select(line, 0, line + 1, 0)
delete_selection()
# Trigger content changed
text_changed.emit()
func copy_selection() -> void:
if has_selection():
# Copy selected text to clipboard
DisplayServer.clipboard_set(get_selected_text())
else:
# If no selection, copy the current line
var line := get_caret_line()
var line_text := get_line(line)
DisplayServer.clipboard_set(line_text)
func paste_clipboard() -> void:
# Get clipboard content
var clipboard = DisplayServer.clipboard_get()
if clipboard.is_empty():
return
if has_selection():
# Replace selected text with clipboard content
delete_selection()
# Insert clipboard content at caret position
insert_text_at_caret(clipboard)
text_changed.emit()
# Zoom management functions
func zoom_in() -> void:
zoom_level = min(zoom_level + 0.07, 3.0) # Max 200%
_update_font_size()
zoom_changed.emit(zoom_level)
func zoom_out() -> void:
zoom_level = max(zoom_level - 0.07, 0.25) # Min 50%
_update_font_size()
zoom_changed.emit(zoom_level)
func zoom_reset() -> void:
zoom_level = 1.0
_update_font_size()
zoom_changed.emit(zoom_level)
func set_zoom(zoom: float) -> void:
zoom_level = max(0.25, min(3.0, zoom))
_update_font_size()
zoom_changed.emit(zoom_level)
func _update_font_size() -> void:
var new_size = int(default_font_size * zoom_level)
add_theme_font_size_override("font_size", new_size)
func _unhandled_key_input(event: InputEvent) -> void:
# Handle tab key before focus system gets it
if event is InputEventKey and event.pressed and has_focus():
match event.keycode:
KEY_TAB:
if event.shift_pressed:
# Handle Shift+Tab for unindent
_handle_unindent()
else:
# Handle Tab for indent
_handle_indent()
get_viewport().set_input_as_handled()
return
func _gui_input(event: InputEvent) -> void:
# Handle shortcuts for saving/closing
if event is InputEventKey and event.pressed:
match event.get_keycode_with_modifiers():
KEY_MASK_CTRL | KEY_S:
save_requested.emit()
get_viewport().set_input_as_handled()
KEY_MASK_CTRL | KEY_W:
close_requested.emit()
get_viewport().set_input_as_handled()
KEY_ENTER, KEY_KP_ENTER:
# Handle auto-continuation of YAML structures
_handle_enter_key()
get_viewport().set_input_as_handled()
KEY_MASK_CTRL | KEY_Z:
# Handle undo
undo_requested.emit()
get_viewport().set_input_as_handled()
KEY_MASK_CTRL | KEY_Y, KEY_MASK_CTRL | KEY_MASK_SHIFT | KEY_Z:
# Handle redo (supports both Ctrl+Y and Ctrl+Shift+Z)
redo_requested.emit()
get_viewport().set_input_as_handled()
KEY_MASK_CTRL | KEY_EQUAL, KEY_MASK_CTRL | KEY_KP_ADD:
zoom_in()
KEY_MASK_CTRL | KEY_MINUS, KEY_MASK_CTRL | KEY_KP_SUBTRACT:
zoom_out()
KEY_MASK_CTRL | KEY_0, KEY_MASK_CTRL | KEY_KP_0:
zoom_reset()
if event is InputEventMouseButton and event.pressed and event.is_command_or_control_pressed():
if event.button_index == MOUSE_BUTTON_WHEEL_UP:
zoom_in()
elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
zoom_out()
func set_text_and_preserve_state(new_text: String, preserve_state: bool = true) -> void:
if preserve_state:
# Save current state
var previous_caret_pos := get_caret_column()
var previous_line := get_caret_line()
var previous_scroll_v := get_v_scroll_bar().value
var previous_scroll_h := get_h_scroll_bar().value
# Set text without triggering our own text_changed handler
suppress_text_changed = true
text = new_text
suppress_text_changed = false
# Restore state if possible
if previous_line < get_line_count():
set_caret_line(previous_line)
var line_length := get_line(previous_line).length()
if previous_caret_pos <= line_length:
set_caret_column(previous_caret_pos)
# Restore scroll position (with a small delay to ensure the text is updated first)
call_deferred("_restore_scroll_position", previous_scroll_v, previous_scroll_h)
else:
# Just set the text without preserving state
suppress_text_changed = true
text = new_text
suppress_text_changed = false
func _restore_scroll_position(v_scroll: float, h_scroll: float) -> void:
# Wait for one frame to ensure the text has been updated and rendered
if get_tree():
await get_tree().process_frame
get_v_scroll_bar().value = v_scroll
get_h_scroll_bar().value = h_scroll
func _handle_indent() -> void:
# Get current line and text
var line := get_caret_line()
var line_text := get_line(line)
# Get selection so we can handle multi-line indentation
var selection_active := has_selection()
var selection_from := get_selection_from_line()
var selection_to := get_selection_to_line()
if selection_active:
# Indent multiple lines
begin_complex_operation()
for i in range(selection_from, selection_to + 1):
set_line(i, " " + get_line(i))
end_complex_operation()
else:
# Simple indent - insert 2 spaces at caret position
insert_text_at_caret(" ")
# Trigger text changed to update the document
text_changed.emit()
func _handle_unindent() -> void:
# Get current line and text
var line := get_caret_line()
var text := get_line(line)
# Get selection so we can handle multi-line unindentation
var selection_active := has_selection()
var selection_from := get_selection_from_line()
var selection_to := get_selection_to_line()
if selection_active:
# Unindent multiple lines
begin_complex_operation()
for i in range(selection_from, selection_to + 1):
var line_text := get_line(i)
if line_text.begins_with(" "):
set_line(i, line_text.substr(2))
elif line_text.begins_with(" "):
set_line(i, line_text.substr(1))
end_complex_operation()
else:
# Simple unindent - remove up to 2 spaces from beginning of line
if text.begins_with(" "):
set_line(line, text.substr(2))
set_caret_column(max(0, get_caret_column() - 2))
elif text.begins_with(" "):
set_line(line, text.substr(1))
set_caret_column(max(0, get_caret_column() - 1))
func _handle_enter_key() -> void:
var line := get_caret_line()
var line_text := get_line(line)
# Auto-continuation for lists
if "- " in line_text:
var indent_level := 0
for c in line_text:
if c == ' ':
indent_level += 1
else:
break
# Insert the line with the same indentation and list marker
var new_line := "\n" + " ".repeat(indent_level) + "- "
insert_text_at_caret(new_line)
else:
# Regular line break with preserved indentation
var indent_level := 0
for c in line_text:
if c == ' ':
indent_level += 1
else:
break
# Increased indentation if the line ends with a colon
if line_text.strip_edges().ends_with(":"):
indent_level += 2
insert_text_at_caret("\n" + " ".repeat(indent_level))
func register_yaml_code_completion() -> void:
# Register common YAML keywords and patterns for code completion
var keyword_list: PackedStringArray = [
"true",
"false",
"null",
"~",
"INF",
"-INF"
]
# Add keywords and tags to completion
for keyword in keyword_list:
add_code_completion_option(CodeCompletionKind.KIND_CONSTANT, keyword, keyword)
## YAML tags
var tag_list: PackedStringArray = [
"!Resource",
"!AABB",
"!Basis",
"!Color",
"!NodePath",
"!PackedByteArray",
"!PackedColorArray",
"!PackedFloat32Array",
"!PackedFloat64Array",
"!PackedInt32Array",
"!PackedInt64Array",
"!PackedStringArray",
"!PackedVector2Array",
"!PackedVector3Array",
"!Plane",
"!Projection",
"!Quaternion",
"!Rect2",
"!Rect2i",
"!StringName",
"!Transform2D",
"!Transform3D",
"!Vector2",
"!Vector2i",
"!Vector3",
"!Vector3i",
"!Vector4",
"!Vector4i"
]
for tag in tag_list:
add_code_completion_option(CodeCompletionKind.KIND_CLASS, tag, tag)
func mark_error_line(line: int, message: String) -> void:
if line < 0 or line >= get_line_count():
return
# Set line background to error color
var error_color: Color = EditorInterface.get_editor_settings().get_setting("text_editor/theme/highlighting/mark_color")
set_line_background_color(line, error_color)
# Set gutter icon
var error_icon := get_theme_icon("StatusError", "EditorIcons")
if error_icon:
set_line_gutter_icon(line, 0, error_icon)
# Store for later reference
error_indicators[line] = message
func clear_error_indicators() -> void:
for line: int in error_indicators:
set_line_background_color(line, Color(0, 0, 0, 0))
set_line_gutter_icon(line, 0, null)
error_indicators.clear()
func get_current_line_col_info() -> Array[int]:
var line := get_caret_line() + 1
var col := get_caret_column() + 1
return [line, col]
+1
View File
@@ -0,0 +1 @@
uid://drkui6da5o1ou
+136
View File
@@ -0,0 +1,136 @@
@tool
class_name YAMLEditorDocument extends RefCounted
# Primary document data
var path: String
var content: String
var is_modified: bool = false
var validation_result: YAMLResult
# History management
class YAMLEditorHistoryState extends RefCounted:
var text: String
var caret_line: int = 0
var caret_column: int = 0
func _init(p_text: String, p_line: int = 0, p_column: int = 0) -> void:
text = p_text
caret_line = p_line
caret_column = p_column
func _to_string() -> String:
return "YAMLEditorHistoryState(text_length=%d, line=%d, column=%d)" % [text.length(), caret_line, caret_column]
# Limit history size to prevent excessive memory use
const MAX_HISTORY := 100
var history_states: Array[YAMLEditorHistoryState] = []
var current_history_index: int = -1
var saved_history_index: int = -1
# Signals
signal content_changed(document)
signal validation_changed(document)
signal modified_changed(document)
# Constructor
func _init(p_path: String, p_content: String = "") -> void:
path = p_path
content = p_content
validation_result = YAMLResult.new() # Empty result
# Take initial snapshot if content isn't empty
if not p_content.is_empty():
_add_history_state(YAMLEditorHistoryState.new(p_content))
# File path utilities
func get_file_name() -> String:
return path.get_file()
func is_untitled() -> bool:
return path.begins_with("untitled")
# Content management
func set_content(new_content: String, caret_line: int = 0, caret_column: int = 0) -> void:
if content == new_content:
return
content = new_content
_add_history_state(YAMLEditorHistoryState.new(new_content, caret_line, caret_column))
set_modified(true)
content_changed.emit(self)
# Modification state
func set_modified(modified: bool) -> void:
if is_modified == modified:
return
is_modified = modified
modified_changed.emit(self)
# Validation management
func set_validation_result(result: YAMLResult) -> void:
validation_result = result
validation_changed.emit(self)
func has_error() -> bool:
return validation_result and validation_result.has_error()
# History management
func can_undo() -> bool:
return current_history_index > 0 # Need at least one previous state
func can_redo() -> bool:
return current_history_index < history_states.size() - 1
func undo() -> YAMLEditorHistoryState:
if not can_undo():
return null
current_history_index -= 1
var state := history_states[current_history_index]
content = state.text
# Update modification state
set_modified(current_history_index != saved_history_index)
content_changed.emit(self)
return state
func redo() -> YAMLEditorHistoryState:
if not can_redo():
return null
current_history_index += 1
var state := history_states[current_history_index]
content = state.text
# Update modification state
set_modified(current_history_index != saved_history_index)
content_changed.emit(self)
return state
func mark_saved() -> void:
saved_history_index = current_history_index
set_modified(false)
func _add_history_state(state: YAMLEditorHistoryState) -> void:
# If we're not at the end of history, truncate future states
if current_history_index < history_states.size() - 1:
history_states = history_states.slice(0, current_history_index + 1)
# Add the new state
history_states.append(state)
current_history_index = history_states.size() - 1
if history_states.size() > MAX_HISTORY:
var excess := history_states.size() - MAX_HISTORY
history_states = history_states.slice(excess)
current_history_index -= excess
# Adjust saved index if needed
if saved_history_index >= 0:
saved_history_index -= excess
if saved_history_index < 0:
saved_history_index = -1
+1
View File
@@ -0,0 +1 @@
uid://5ow36wsuc7uj
+578
View File
@@ -0,0 +1,578 @@
@tool
class_name YAMLEditorDocumentManager extends Node
signal document_changed(document)
signal document_created(document)
signal document_closed(document)
# Dictionary of open documents: {path: YAMLEditorDocument}
var documents: Dictionary = {}
var current_document: YAMLEditorDocument = null
# UI components
var file_list: YAMLEditorFileList
var code_editor: YAMLCodeEditor
var file_popup_menu: PopupMenu
var editor_node: Control
# Reference to the singleton
var file_system: YAMLFileSystem
# Track recently saved files to avoid external update conflicts
var recently_saved_files: Dictionary = {}
var ignore_update_timer: Timer
func _init(_editor: Control) -> void:
editor_node = _editor
func _ready() -> void:
# Get singleton reference
file_system = YAMLFileSystem.get_singleton()
# Listen for external file updates
file_system.file_updated.connect(_on_external_file_updated)
file_system.file_renamed.connect(_on_file_renamed)
# Create file popup menu
file_popup_menu = PopupMenu.new()
add_child(file_popup_menu)
# Add menu items
file_popup_menu.add_item("Save", 0)
file_popup_menu.add_item("Save As...", 1)
file_popup_menu.add_separator()
file_popup_menu.add_item("Close", 2)
file_popup_menu.add_separator()
file_popup_menu.add_item("Show in FileSystem", 3)
# Connect popup menu signals
file_popup_menu.id_pressed.connect(_on_file_popup_menu_id_pressed)
# Create timer for clearing recent saves
ignore_update_timer = Timer.new()
add_child(ignore_update_timer)
ignore_update_timer.one_shot = true
ignore_update_timer.wait_time = 0.5 # 500ms
ignore_update_timer.timeout.connect(_on_ignore_update_timer_timeout)
# Update UI
update_ui()
func setup(p_file_list: YAMLEditorFileList, p_code_editor: YAMLCodeEditor) -> void:
file_list = p_file_list
code_editor = p_code_editor
# Connect signals from file list component
file_list.file_selected.connect(_on_file_selected)
file_list.file_context_requested.connect(_on_file_context_requested)
func create_document(path: String, content: String = "") -> YAMLEditorDocument:
var normalized_path = _normalize_path(path)
# Check if document already exists with normalized path
if documents.has(normalized_path):
return documents[normalized_path]
var document := YAMLEditorDocument.new(normalized_path, content)
# Connect document signals
document.content_changed.connect(_on_document_content_changed)
document.modified_changed.connect(_on_document_modified_changed)
# Store document
documents[normalized_path] = document
document_created.emit(document)
return document
func open_file(path: String) -> void:
var normalized_path = _normalize_path(path)
# Check if already open
if documents.has(normalized_path):
set_current_document(documents[normalized_path])
return
# Look for any document with the same base filename
var filename = normalized_path.get_file()
for existing_path in documents.keys():
if existing_path.get_file() == filename and existing_path != normalized_path:
# Check if they point to the same actual file
if _paths_point_to_same_file(normalized_path, existing_path):
set_current_document(documents[existing_path])
return
# Use the file system singleton to read the file
var content := file_system.read_file(normalized_path)
if typeof(content) == TYPE_INT: # Error code
push_error("Could not open file '%s': %s" % [normalized_path, error_string(content)])
return
# Create new document
var document := create_document(normalized_path, content)
document.mark_saved() # Initial state is saved
# Switch to the new document
set_current_document(document)
# Notify the file system
file_system.notify_file_opened(normalized_path)
func close_document(document: YAMLEditorDocument) -> bool:
if document == null:
return true
if document.is_modified:
# Show confirmation dialog for unsaved changes
var dialog := ConfirmationDialog.new()
dialog.title = "Unsaved Changes"
dialog.dialog_text = "Save changes to '" + document.get_file_name() + "' before closing?"
dialog.add_button("Don't Save", true, "dont_save")
dialog.add_cancel_button("Cancel")
dialog.confirmed.connect(
func():
# Save was chosen
if save_document(document):
_close_document_internal(document)
dialog.queue_free()
)
dialog.custom_action.connect(
func(action):
if action == "dont_save":
_close_document_internal(document)
dialog.queue_free()
)
dialog.canceled.connect(func(): dialog.queue_free())
add_child(dialog)
dialog.popup_centered()
return false
return _close_document_internal(document)
func _close_document_internal(document: YAMLEditorDocument) -> bool:
if document == null:
return false
# Find the document in our dictionary
var path_to_remove = ""
for path in documents.keys():
if documents[path] == document:
path_to_remove = path
break
if path_to_remove.is_empty():
return false
# Notify document is being closed
document_closed.emit(document)
# Remove document
documents.erase(document.path)
# If this was the current document, switch to another
if current_document == document:
current_document = null
# Select another document if available
if not documents.is_empty():
set_current_document(documents.values()[0])
else:
# Clear the editor if no documents left
code_editor.text = ""
# Update UI
update_ui()
# Notify the file system
file_system.notify_file_closed(document.path)
return true
func save_document(document: YAMLEditorDocument) -> bool:
if document == null:
return false
# Don't save untitled files directly
if document.is_untitled():
return false # Caller should handle "Save As" dialog
# Mark this file as recently saved to ignore update notifications
recently_saved_files[document.path] = Time.get_unix_time_from_system()
ignore_update_timer.start()
# Use the file system singleton to save the file
var result := file_system.save_file(document.path, document.content)
if result != OK:
push_error("Could not save file '%s': %s" % [document.path, error_string(result)])
recently_saved_files.erase(document.path) # Remove from recently saved if error
return false
# Mark document as saved
document.mark_saved()
# Update UI
update_ui()
return true
func save_document_as(document: YAMLEditorDocument, new_path: String) -> bool:
if document == null or new_path.is_empty():
return false
var normalized_new_path = _normalize_path(new_path)
# Check if we're trying to save to a path that's already open
if documents.has(normalized_new_path) and documents[normalized_new_path] != document:
push_error("Cannot save as '%s' - file is already open" % normalized_new_path)
return false
# Remember the old path
var old_path := document.path
# Update document path
document.path = normalized_new_path
# Update the documents dictionary
if old_path != normalized_new_path:
documents.erase(old_path)
documents[normalized_new_path] = document
# Save the document
if save_document(document):
# If old path was temporary, clean up
if old_path != normalized_new_path and old_path.begins_with("untitled"):
file_system.notify_file_closed(old_path)
# Update UI
update_ui()
# Notify file system
file_system.notify_file_opened(new_path)
return true
# Restore old path if save failed
if old_path != normalized_new_path:
document.path = old_path
documents.erase(normalized_new_path)
documents[old_path] = document
return false
func new_file() -> void:
# Create a new untitled file
var untitled_name := "untitled.yaml"
var index := 1
while documents.has(untitled_name):
index += 1
untitled_name = "untitled%d.yaml" % index
# Create a new document
var document := create_document(untitled_name)
document.set_modified(true) # New document is always modified
# Switch to the new document
set_current_document(document)
# Set focus to code editor
code_editor.grab_focus()
# Notify the file system
file_system.notify_file_opened(untitled_name)
func set_current_document(document: YAMLEditorDocument) -> void:
if document == null or document == current_document:
return
current_document = document
# Update editor content
if is_instance_valid(code_editor):
code_editor.set_text_and_preserve_state(document.content)
# Update UI
update_ui()
# Emit signal
document_changed.emit(document)
func update_document_content(document: YAMLEditorDocument, new_content: String) -> void:
if document == null:
return
var caret_line := 0
var caret_column := 0
if is_instance_valid(code_editor):
caret_line = code_editor.get_caret_line()
caret_column = code_editor.get_caret_column()
document.set_content(new_content, caret_line, caret_column)
func _normalize_path(path: String) -> String:
# Convert to absolute path and normalize
var normalized = path
# Handle different path formats
if normalized.begins_with("res://"):
normalized = ProjectSettings.globalize_path(normalized)
# Convert to canonical form
normalized = normalized.simplify_path()
# Convert back to res:// format if it was originally a project path
if path.begins_with("res://"):
normalized = ProjectSettings.localize_path(normalized)
return normalized
func _paths_point_to_same_file(path1: String, path2: String) -> bool:
# For project files, compare the res:// paths
if path1.begins_with("res://") and path2.begins_with("res://"):
return path1 == path2
# For absolute paths, normalize and compare
var abs_path1 = ProjectSettings.globalize_path(path1) if path1.begins_with("res://") else path1
var abs_path2 = ProjectSettings.globalize_path(path2) if path2.begins_with("res://") else path2
return abs_path1.simplify_path() == abs_path2.simplify_path()
func _on_document_content_changed(document: YAMLEditorDocument) -> void:
# Update UI if this is the current document
if document == current_document:
update_ui()
func _on_document_modified_changed(document: YAMLEditorDocument) -> void:
# Update UI if this is the current document
if document == current_document:
update_ui()
func update_ui() -> void:
# Toggle editor visibility
editor_node.visible = documents.size() > 0
if not is_instance_valid(file_list):
return
# Prepare file data for the file list component
var file_data := {}
for path in documents:
var document = documents[path]
file_data[path] = {
"name": document.get_file_name(),
"modified": document.is_modified
}
# Update the file list component
var current_path = current_document.path if current_document else ""
file_list.update_files(file_data, current_path)
func _on_file_selected(path: String) -> void:
if path.is_empty():
return
var normalized_path = _normalize_path(path)
if not documents.has(normalized_path):
return
set_current_document(documents[normalized_path])
func _on_file_context_requested(path: String, at_position: Vector2) -> void:
if path.is_empty():
return
var normalized_path = _normalize_path(path)
if not documents.has(normalized_path):
return
set_current_document(documents[normalized_path])
# Calculate the global position for the popup
var global_rect := Rect2(file_list.get_global_mouse_position(), Vector2.ZERO)
file_popup_menu.popup_on_parent(global_rect)
func _on_file_popup_menu_id_pressed(id: int) -> void:
var path := file_list.get_selected_file_path()
if path.is_empty():
return
var normalized_path = _normalize_path(path)
if not documents.has(normalized_path):
return
var document: YAMLEditorDocument = documents[normalized_path]
match id:
0: # Save
save_document(document)
1: # Save As
# Main editor should handle the save as dialog
set_current_document(document)
2: # Close
close_document(document)
3: # Show in FileSystem
if not document.is_untitled() and document.path.begins_with("res://"):
EditorInterface.get_file_system_dock().navigate_to_path(document.path)
func _on_external_file_updated(path: String) -> void:
var normalized_path = _normalize_path(path)
# Only process if the file is open and it's a YAML file
if documents.has(normalized_path) and file_system.is_yaml_file(normalized_path):
# Check if we just saved this file ourselves
if recently_saved_files.has(normalized_path):
var save_time: int = recently_saved_files[normalized_path]
var current_time := Time.get_unix_time_from_system()
# If saved less than 1 second ago, ignore this update
if current_time - save_time < 1.0:
return
var document: YAMLEditorDocument = documents[normalized_path]
# Check if the document has unsaved changes
if not document.is_modified:
# Document is not modified locally, safe to reload
var content = file_system.read_file(normalized_path)
if typeof(content) != TYPE_INT: # Not an error
# Update document content
document.content = content
document.mark_saved()
# If this is the current document, update the editor
if document == current_document:
# Preserve cursor position and scroll state
var previous_caret_line := code_editor.get_caret_line()
var previous_caret_column := code_editor.get_caret_column()
var previous_scroll_v := code_editor.get_v_scroll_bar().value
var previous_scroll_h := code_editor.get_h_scroll_bar().value
code_editor.text = content
# Restore position if possible
if previous_caret_line < code_editor.get_line_count():
code_editor.set_caret_line(previous_caret_line)
var line_length := code_editor.get_line(previous_caret_line).length()
if previous_caret_column <= line_length:
code_editor.set_caret_column(previous_caret_column)
# Restore scroll position
code_editor.get_v_scroll_bar().value = previous_scroll_v
code_editor.get_h_scroll_bar().value = previous_scroll_h
update_ui()
else:
# Document has unsaved changes, show conflict dialog
if document == current_document:
var dialog := ConfirmationDialog.new()
dialog.title = "External Changes Detected"
dialog.dialog_text = "The file '" + document.get_file_name() + "' has been modified externally. Do you want to reload it and lose your changes?"
dialog.confirmed.connect(
func():
var content := file_system.read_file(path)
if typeof(content) != TYPE_INT:
document.content = content
document.mark_saved()
if document == current_document:
code_editor.text = content
update_ui()
dialog.queue_free()
)
dialog.canceled.connect(func(): dialog.queue_free())
add_child(dialog)
dialog.popup_centered()
func _on_ignore_update_timer_timeout() -> void:
# Clear out any old saved entries
var current_time := Time.get_unix_time_from_system()
var keys_to_remove: PackedStringArray = []
for path in recently_saved_files:
var save_time = recently_saved_files[path]
if current_time - save_time >= 1.0:
keys_to_remove.append(path)
for path in keys_to_remove:
recently_saved_files.erase(path)
func _on_file_renamed(old_path: String, new_path: String) -> void:
var normalized_old_path = _normalize_path(old_path)
var normalized_new_path = _normalize_path(new_path)
# If we have this document open, update our references
if documents.has(normalized_old_path):
var document: YAMLEditorDocument = documents[normalized_old_path]
document.path = normalized_new_path
documents.erase(normalized_old_path)
documents[normalized_new_path] = document
update_ui()
func handle_filesystem_change() -> void:
# Check if any of our open res:// files no longer exist
var missing_files: PackedStringArray = []
for path in documents.keys():
if path.begins_with("res://") and not file_system.file_exists(path):
missing_files.append(path)
# Handle missing files
for old_path in missing_files:
var document: YAMLEditorDocument = documents[old_path]
# Try to find a file with the same name but different path in the filesystem
var filename := old_path.get_file()
var filesystem_root := EditorInterface.get_resource_filesystem().get_filesystem()
var new_path := file_system.find_file_in_filesystem(filesystem_root, filename)
if not new_path.is_empty():
var normalized_new_path = _normalize_path(new_path)
# Found potential match - update the document path
document.path = normalized_new_path
documents.erase(old_path)
documents[normalized_new_path] = document
# If this is the current document, emit signal
if document == current_document:
document_changed.emit(document)
# Update UI
update_ui()
# Notify file system
file_system.notify_file_closed(old_path)
file_system.notify_file_opened(normalized_new_path)
file_system.notify_file_renamed(old_path, normalized_new_path)
else:
# Keep it open but mark as potentially moved/deleted to avoid losing unsaved changes
pass
func has_unsaved_changes() -> bool:
for document in documents.values():
if document.is_modified:
return true
return false
func get_open_documents() -> Array:
return documents.values()
func get_open_paths() -> Array:
return documents.keys()
func has_document(path: String) -> bool:
return documents.has(path)
func get_document(path: String) -> YAMLEditorDocument:
return documents.get(path, null)
func get_current_document() -> YAMLEditorDocument:
return current_document
@@ -0,0 +1 @@
uid://cpm2siqxhgj8w
+166
View File
@@ -0,0 +1,166 @@
@tool
class_name YAMLEditorShortcuts
# This helper class registers editor shortcuts for the YAML editor
const SHORTCUTS = [
{
"name": "Save",
"shortcut": KEY_MASK_CTRL | KEY_S,
"callback": "_on_save_shortcut"
},
{
"name": "Save As",
"shortcut": KEY_MASK_CTRL | KEY_MASK_SHIFT | KEY_S,
"callback": "_on_save_as_shortcut"
},
{
"name": "Close File",
"shortcut": KEY_MASK_CTRL | KEY_W,
"callback": "_on_close_shortcut"
},
{
"name": "New File",
"shortcut": KEY_MASK_CTRL | KEY_N,
"callback": "_on_new_shortcut"
},
{
"name": "Open File",
"shortcut": KEY_MASK_CTRL | KEY_O,
"callback": "_on_open_shortcut"
},
{
"name": "Validate YAML",
"shortcut": KEY_F4,
"callback": "_on_validate_shortcut"
},
{
"name": "Undo",
"shortcut": KEY_MASK_CTRL | KEY_Z,
"callback": "_on_undo_shortcut"
},
{
"name": "Redo",
"shortcut": KEY_MASK_CTRL | KEY_MASK_SHIFT | KEY_Z,
"callback": "_on_redo_shortcut"
},
{
"name": "Cut",
"shortcut": KEY_MASK_CTRL | KEY_X,
"callback": "_on_cut_shortcut"
},
{
"name": "Copy",
"shortcut": KEY_MASK_CTRL | KEY_C,
"callback": "_on_copy_shortcut"
},
{
"name": "Paste",
"shortcut": KEY_MASK_CTRL | KEY_V,
"callback": "_on_paste_shortcut"
},
{
"name": "Select All",
"shortcut": KEY_MASK_CTRL | KEY_A,
"callback": "_on_select_all_shortcut"
},
{
"name": "Find",
"shortcut": KEY_MASK_CTRL | KEY_F,
"callback": "_on_find_shortcut"
},
{
"name": "Find Next",
"shortcut": KEY_F3,
"callback": "_on_find_next_shortcut"
},
{
"name": "Find Previous",
"shortcut": KEY_MASK_SHIFT | KEY_F3,
"callback": "_on_find_previous_shortcut"
},
{
"name": "Replace",
"shortcut": KEY_MASK_CTRL | KEY_R,
"callback": "_on_replace_shortcut"
},
# Zoom shortcuts
{
"name": "Zoom In",
"shortcut": KEY_MASK_CTRL | KEY_EQUAL,
"callback": "_on_zoom_in_shortcut"
},
{
"name": "Zoom In (Numpad)",
"shortcut": KEY_MASK_CTRL | KEY_KP_ADD,
"callback": "_on_zoom_in_shortcut"
},
{
"name": "Zoom Out",
"shortcut": KEY_MASK_CTRL | KEY_MINUS,
"callback": "_on_zoom_out_shortcut"
},
{
"name": "Zoom Out (Numpad)",
"shortcut": KEY_MASK_CTRL | KEY_KP_SUBTRACT,
"callback": "_on_zoom_out_shortcut"
},
{
"name": "Reset Zoom",
"shortcut": KEY_MASK_CTRL | KEY_0,
"callback": "_on_zoom_reset_shortcut"
},
{
"name": "Reset Zoom (Numpad)",
"shortcut": KEY_MASK_CTRL | KEY_KP_0,
"callback": "_on_zoom_reset_shortcut"
}
]
static func register_shortcuts(editor_plugin: EditorPlugin, target_object: Object) -> void:
# Create shortcut inputs for the YAML editor
var editor_settings := editor_plugin.get_editor_interface().get_editor_settings()
var shortcuts_settings := editor_settings.get_setting("shortcuts") if editor_settings.has_setting("shortcuts") else {}
# Create a unique editor name for our shortcuts
var editor_name := "YAML Editor"
# Register each shortcut
for shortcut_data in SHORTCUTS:
var shortcut_name: String = "yaml_editor/" + shortcut_data.name.to_lower().replace(" ", "_")
var input_event := InputEventKey.new()
input_event.keycode = shortcut_data.shortcut
# Create a shortcut
var shortcut := Shortcut.new()
shortcut.events = [input_event]
# Register the shortcut with Godot's input map
if not InputMap.has_action(shortcut_name):
InputMap.add_action(shortcut_name)
InputMap.action_add_event(shortcut_name, input_event)
# Connect to the target object's _unhandled_key_input method if it exists
if !target_object.has_method("_unhandled_key_input"):
# Create connections for shortcuts if the target doesn't handle key input directly
for shortcut_data in SHORTCUTS:
var shortcut_name: String = "yaml_editor/" + shortcut_data.name.to_lower().replace(" ", "_")
if target_object.has_method(shortcut_data.callback):
InputMap.action_add_event(shortcut_name, InputEventAction.new())
# Connect the shortcut action to the target's callback
var root := editor_plugin.get_tree().root
root.connect("input_event",
func(event):
if event is InputEventKey and event.pressed:
if event.get_keycode_with_modifiers() == shortcut_data.shortcut:
target_object.call(shortcut_data.callback)
print("called a thing")
root.get_viewport().set_input_as_handled()
)
static func unregister_shortcuts() -> void:
# Remove all registered shortcuts
for shortcut_data in SHORTCUTS:
var shortcut_name: String = "yaml_editor/" + shortcut_data.name.to_lower().replace(" ", "_")
if InputMap.has_action(shortcut_name):
InputMap.erase_action(shortcut_name)
@@ -0,0 +1 @@
uid://dgcufn1xonjkp
+107
View File
@@ -0,0 +1,107 @@
@tool
class_name YAMLEditorFileList extends VBoxContainer
signal file_selected(path)
signal file_context_requested(path, position)
# References to UI components
@export var filter_input: LineEdit
@export var file_list: ItemList
# File data
var files: Dictionary = {} # {path: {name, modified}}
var filtered_files: Array = []
var current_path: String = ""
func _ready() -> void:
# SessionManager will handle loading of the files
file_list.clear()
# Connect internal signals
if is_instance_valid(file_list):
file_list.item_selected.connect(_on_item_selected)
file_list.item_clicked.connect(_on_item_clicked)
if is_instance_valid(filter_input):
filter_input.text_changed.connect(_on_filter_text_changed)
filter_input.right_icon = get_theme_icon("Search", "EditorIcons")
# Public API
func update_files(p_files: Dictionary, p_current_path: String) -> void:
files = p_files.duplicate()
current_path = p_current_path
_update_ui()
func mark_file_modified(path: String, is_modified: bool) -> void:
if files.has(path):
files[path].modified = is_modified
_update_ui()
func get_selected_file_path() -> String:
if not is_instance_valid(file_list):
return ""
var selected_items := file_list.get_selected_items()
if selected_items.is_empty():
return ""
var selected_index := selected_items[0]
if selected_index >= 0 and selected_index < filtered_files.size():
return filtered_files[selected_index]
return ""
# UI update
func _update_ui() -> void:
if not is_instance_valid(file_list):
return
var current_selection := get_selected_file_path()
file_list.clear()
filtered_files.clear()
var filter_text := filter_input.text.to_lower() if is_instance_valid(filter_input) else ""
var current_index := -1
var index := 0
for path: String in files.keys():
var file_data: Dictionary = files[path]
var file_name := path.get_file()
if not filter_text.is_empty() and file_name.to_lower().find(filter_text) == -1:
continue
var display_name := file_name
if file_data.modified:
display_name += " (*)"
file_list.add_item(display_name)
filtered_files.append(path)
file_list.set_item_tooltip(index, path)
if path == current_path:
current_index = index
if path == current_selection:
file_list.select(index)
index += 1
if current_index >= 0 and (file_list.get_selected_items().is_empty() or current_path != current_selection):
file_list.select(current_index)
# Signal handlers
func _on_filter_text_changed(_text: String) -> void:
_update_ui()
func _on_item_selected(index: int) -> void:
if index >= 0 and index < filtered_files.size():
file_selected.emit(filtered_files[index])
func _on_item_clicked(index: int, at_position: Vector2, mouse_button_index: int) -> void:
if index >= 0 and index < filtered_files.size():
if mouse_button_index == MOUSE_BUTTON_RIGHT:
file_list.select(index)
file_context_requested.emit(filtered_files[index], at_position)
+1
View File
@@ -0,0 +1 @@
uid://c15odacm31d03
+93
View File
@@ -0,0 +1,93 @@
@tool
class_name YAMLFileSystem extends Node
signal file_opened(path)
signal file_saved(path)
signal file_updated(path)
signal file_closed(path)
signal file_renamed(old_path, new_path)
# Singleton pattern
static var _instance: YAMLFileSystem
static func get_singleton() -> YAMLFileSystem:
if not _instance:
_instance = YAMLFileSystem.new()
Engine.get_main_loop().root.call_deferred("add_child", _instance)
return _instance
func _init() -> void:
if _instance != null:
push_error("YAMLFileSystem singleton already exists")
return
_instance = self
# Mark as persistent so it doesn't get destroyed on scene changes
process_mode = Node.PROCESS_MODE_ALWAYS
# File operations with signals
func save_file(path: String, content: String) -> Error:
var was_new_file = not file_exists(path)
var file := FileAccess.open(path, FileAccess.WRITE)
if not file:
return FileAccess.get_open_error()
file.store_string(content)
file_saved.emit(path)
file_updated.emit(path)
# If this was a new file, notify Godot's filesystem
if was_new_file:
call_deferred("_refresh_filesystem", path)
return OK
func read_file(path: String) -> Variant:
var file := FileAccess.open(path, FileAccess.READ)
if not file:
return FileAccess.get_open_error()
return file.get_as_text()
# Check if a file exists
func file_exists(path: String) -> bool:
return FileAccess.file_exists(path)
# Utility to check if a path is a YAML file
func is_yaml_file(path: String) -> bool:
return path.get_extension().to_lower() in ["yaml", "yml"]
# For external updates, allow code to manually trigger the signal
func notify_file_updated(path: String) -> void:
file_updated.emit(path)
# Called when a file is opened in the editor
func notify_file_opened(path: String) -> void:
file_opened.emit(path)
# Called when a file is closed in the editor
func notify_file_closed(path: String) -> void:
file_closed.emit(path)
# Called when a file is renamed (by the filesystem or editor)
func notify_file_renamed(old_path: String, new_path: String) -> void:
file_renamed.emit(old_path, new_path)
# Find a file by name in the filesystem
func find_file_in_filesystem(dir: EditorFileSystemDirectory, filename: String) -> String:
# Check files in current directory
for i in range(dir.get_file_count()):
var file_path := dir.get_file_path(i)
if file_path.get_file() == filename:
return file_path
# Recursively check subdirectories
for i in range(dir.get_subdir_count()):
var subdir := dir.get_subdir(i)
var result := find_file_in_filesystem(subdir, filename)
if not result.is_empty():
return result
return ""
func _refresh_filesystem(path: String) -> void:
EditorInterface.get_resource_filesystem().update_file(path)
+1
View File
@@ -0,0 +1 @@
uid://p8fujg7vubpn
+496
View File
@@ -0,0 +1,496 @@
@tool
class_name YAMLEditorFindReplaceBar extends Control
signal replace_performed
signal replace_all_performed
@export_category("Find Panel Components")
@export var find_input: LineEdit
@export var matches_label: Label
@export var previous_button: Button
@export var next_button: Button
@export var match_case_checkbox: CheckBox
@export var whole_words_checkbox: CheckBox
@export var find_button_container: HBoxContainer
@export var find_options_container: HBoxContainer
@export_category("Replace Panel Components")
@export var replace_input: LineEdit
@export var replace_button: Button
@export var replace_all_button: Button
@export var selection_only_checkbox: CheckBox
@export var replace_button_container: HBoxContainer
@export var replace_options_container: HBoxContainer
@export_category("Visibility Toggle")
@export var hide_button: Button
@export_category("Node References")
@export var editor: YAMLCodeEditor
@export var vbox_container: VBoxContainer # Container holding both panels
@export var find_panel: Control # First row (find)
@export var replace_panel: Control # Second row (replace)
# Matching state
var matches: Array[Vector2i] = [] # Store line/column pairs of matches
var current_match_index: int = -1 # Index of current selection in matches array
var search_regex: RegEx = RegEx.new()
func _ready() -> void:
# Setup UI
previous_button.icon = get_theme_icon("MoveUp", "EditorIcons")
next_button.icon = get_theme_icon("MoveDown", "EditorIcons")
hide_button.icon = get_theme_icon("Close", "EditorIcons")
# Connect signals
find_input.text_changed.connect(_on_find_input_changed)
find_input.text_submitted.connect(_on_find_input_submitted)
previous_button.pressed.connect(_on_previous_button_pressed)
next_button.pressed.connect(_on_next_button_pressed)
match_case_checkbox.toggled.connect(_on_option_changed)
whole_words_checkbox.toggled.connect(_on_option_changed)
hide_button.pressed.connect(_on_hide_button_pressed)
replace_button.pressed.connect(_on_replace_button_pressed)
replace_all_button.pressed.connect(_on_replace_all_button_pressed)
selection_only_checkbox.toggled.connect(_on_option_changed)
# Disable buttons initially
previous_button.disabled = true
next_button.disabled = true
replace_button.disabled = true
replace_all_button.disabled = true
# Hide by default
visible = false
# Make sure editor preserves selection when focus changes
if editor:
editor.set_deselect_on_focus_loss_enabled(false)
var find_panel_visible: bool:
get(): return find_input.visible
set(value):
find_input.visible = value
find_button_container.visible = value
find_options_container.visible = value
var replace_panel_visible: bool:
get(): return replace_input.visible
set(value):
replace_input.visible = value
replace_button_container.visible = value
replace_options_container.visible = value
# Public methods
func show_find_panel() -> void:
if not is_instance_valid(editor):
return
visible = true
find_panel_visible = true
# If there's a selection, use it as search text
if editor.has_selection():
find_input.text = editor.get_selected_text()
# Run initial search and update UI
trigger_search()
# Focus the search input
find_input.grab_focus()
find_input.select_all()
func show_replace_panel() -> void:
if not is_instance_valid(editor):
return
visible = true
find_panel_visible = true
replace_panel_visible = true
# If there's a selection, use it as search text
if editor.has_selection():
find_input.text = editor.get_selected_text()
# Run initial search and update UI
trigger_search()
# Focus the search input
find_input.grab_focus()
find_input.select_all()
func hide_panel() -> void:
find_panel_visible = false
replace_panel_visible = false
visible = false
# Clear search when hiding
if is_instance_valid(editor):
editor.set_search_text("")
editor.set_search_flags(0)
editor.queue_redraw()
# Core functionality
func trigger_search() -> void:
if not is_instance_valid(editor):
return
# Store old cursor position to find closest match
var old_cursor_line = editor.get_caret_line()
var old_cursor_column = editor.get_caret_column()
# Update TextEdit search settings for highlighting
var search_text = find_input.text
editor.set_search_text(search_text if visible else "")
var flags = 0
if match_case_checkbox.button_pressed:
flags |= TextEdit.SEARCH_MATCH_CASE
if whole_words_checkbox.button_pressed:
flags |= TextEdit.SEARCH_WHOLE_WORDS
editor.set_search_flags(flags)
# Find all matches
matches.clear()
current_match_index = -1
if search_text.is_empty():
_update_match_label()
_update_button_states()
return
# Create regex pattern
_create_search_regex(search_text, match_case_checkbox.button_pressed, whole_words_checkbox.button_pressed)
# Find all matches using regex
for line_num in range(editor.get_line_count()):
var line_text = editor.get_line(line_num)
var search_results = search_regex.search_all(line_text)
for result in search_results:
matches.append(Vector2i(line_num, result.get_start()))
# Determine which match to select
if matches.is_empty():
current_match_index = -1
else:
# Find closest match to current cursor position
var best_distance = -1
var best_match = 0
for i in range(matches.size()):
var pos = matches[i]
# Check if this match is after cursor
if pos.x > old_cursor_line or (pos.x == old_cursor_line and pos.y >= old_cursor_column):
var distance = (pos.x - old_cursor_line) * 1000 + (pos.y - old_cursor_column)
if best_distance < 0 or distance < best_distance:
best_distance = distance
best_match = i
# If no match after cursor, wrap to first match
if best_distance < 0:
current_match_index = 0
else:
current_match_index = best_match
# Always ensure we have a selected match if there are any matches
if matches.size() > 0 and current_match_index == -1:
current_match_index = 0
# Select the current match if appropriate
if current_match_index >= 0 and not (selection_only_checkbox.button_pressed and replace_panel.visible):
_select_current_match()
# Update UI
_update_match_label()
_update_button_states()
func find_next() -> void:
if matches.is_empty() or not is_instance_valid(editor):
return
# Don't navigate if selection only is active
if replace_panel.visible and selection_only_checkbox.button_pressed:
return
# Move to next match
current_match_index = (current_match_index + 1) % matches.size()
_select_current_match()
_update_match_label()
func find_previous() -> void:
if matches.is_empty() or not is_instance_valid(editor):
return
# Don't navigate if selection only is active
if replace_panel.visible and selection_only_checkbox.button_pressed:
return
# Move to previous match
current_match_index = (current_match_index - 1 + matches.size()) % matches.size()
_select_current_match()
_update_match_label()
# Helper methods
func _create_search_regex(search_text: String, case_sensitive: bool, whole_words: bool) -> void:
# Escape special regex characters
var pattern = ""
for i in range(search_text.length()):
var c = search_text[i]
# Escape regex special characters
if c in "\\.*+?^$[](){}|":
pattern += "\\" + c
else:
pattern += c
# Add word boundary anchors if needed
if whole_words:
pattern = "\\b%s\\b" % pattern
if not case_sensitive:
pattern = "(?i)%s" % pattern
search_regex = RegEx.new()
search_regex.compile(pattern)
func _select_current_match() -> void:
if current_match_index < 0 or current_match_index >= matches.size():
return
var match_pos = matches[current_match_index]
var search_length = find_input.text.length()
# Select the text
editor.set_caret_line(match_pos.x)
editor.set_caret_column(match_pos.y)
editor.select(match_pos.x, match_pos.y, match_pos.x, match_pos.y + search_length)
# Center the view
editor.center_viewport_to_caret()
func _update_match_label() -> void:
if not visible:
return
var count = matches.size()
matches_label.visible = true
if count > 0:
matches_label.modulate = Color.WHITE
matches_label.text = "%d of %d matches" % [current_match_index + 1, count]
elif not find_input.text.is_empty():
matches_label.modulate = EditorInterface.get_editor_settings().get_setting("text_editor/theme/highlighting/brace_mismatch_color")
matches_label.text = "No matches"
else:
matches_label.visible = false
matches_label.text = ""
func _update_button_states() -> void:
var has_matches = matches.size() > 0
var selection_only_active = replace_panel.visible and selection_only_checkbox.button_pressed
# Disable navigation buttons if selection only is checked
previous_button.disabled = not has_matches or selection_only_active
next_button.disabled = not has_matches or selection_only_active
# Enable/disable replace buttons
if selection_only_active and editor.has_selection():
var has_matches_in_selection = get_matches_in_selection().size() > 0
replace_button.disabled = not has_matches_in_selection
replace_all_button.disabled = not has_matches_in_selection
else:
replace_button.disabled = not has_matches
replace_all_button.disabled = not has_matches
# Get matches within the current selection when Selection Only is active
func get_matches_in_selection() -> Array[Vector2i]:
var result: Array[Vector2i] = []
if not editor.has_selection() or not selection_only_checkbox.button_pressed:
return matches.duplicate()
var search_text = find_input.text
var selection_from_line = editor.get_selection_from_line()
var selection_from_column = editor.get_selection_from_column()
var selection_to_line = editor.get_selection_to_line()
var selection_to_column = editor.get_selection_to_column()
# Convert selection to absolute character index
var selection_start_index = _get_absolute_index(selection_from_line, selection_from_column)
var selection_end_index = _get_absolute_index(selection_to_line, selection_to_column)
for match_pos in matches:
# Convert match position to absolute character index
var match_start_index = _get_absolute_index(match_pos.x, match_pos.y)
var match_end_index = match_start_index + search_text.length()
# Check if match is fully contained in selection
if match_start_index >= selection_start_index and match_end_index <= selection_end_index:
result.append(match_pos)
return result
# Get the next match after the cursor that's inside the selection
func get_next_match_in_selection() -> Vector2i:
if not editor.has_selection() or not selection_only_checkbox.button_pressed:
return Vector2i(-1, -1)
var matches_in_selection = get_matches_in_selection()
if matches_in_selection.is_empty():
return Vector2i(-1, -1)
var cursor_line = editor.get_caret_line()
var cursor_column = editor.get_caret_column()
# Sort matches by position
matches_in_selection.sort_custom(func(a, b):
if a.x == b.x:
return a.y < b.y
return a.x < b.x
)
# Find the first match after cursor
for match_pos in matches_in_selection:
if match_pos.x > cursor_line or (match_pos.x == cursor_line and match_pos.y >= cursor_column):
return match_pos
# If no match after cursor, wrap to first match
return matches_in_selection[0]
func _get_absolute_index(line: int, column: int) -> int:
# Calculate absolute character index from line and column
var index = 0
for i in range(line):
index += editor.get_line(i).length() + 1 # +1 for newline
index += column
return index
# Signal handlers
func _on_find_input_changed(_text: String) -> void:
trigger_search()
func _on_find_input_submitted(_text: String) -> void:
find_next()
func _on_option_changed(_toggled: bool) -> void:
trigger_search()
func _on_previous_button_pressed() -> void:
find_previous()
func _on_next_button_pressed() -> void:
find_next()
func _on_hide_button_pressed() -> void:
hide_panel()
func _on_replace_button_pressed() -> void:
if not is_instance_valid(editor):
return
var search_text = find_input.text
if search_text.is_empty() or matches.is_empty():
return
var replace_text = replace_input.text
# Different behavior based on Selection Only mode
if selection_only_checkbox.button_pressed and editor.has_selection():
# Get next match in selection
var match_pos = get_next_match_in_selection()
if match_pos.x < 0: # No match in selection
return
# Replace the text
var line_text = editor.get_line(match_pos.x)
var new_line_text = line_text.substr(0, match_pos.y) + replace_text + line_text.substr(match_pos.y + search_text.length())
editor.set_line(match_pos.x, new_line_text)
# Move cursor to the end of the replaced text for next find
var cursor_line = match_pos.x
var cursor_column = match_pos.y + replace_text.length()
# Update the document's content
editor.text_changed.emit()
# Refresh search
trigger_search()
# Restore cursor position for next replacement
editor.set_caret_line(cursor_line)
editor.set_caret_column(cursor_column)
else:
# Normal replace mode - use the current highlighted match
if current_match_index < 0 or current_match_index >= matches.size():
return
# Get current match
var match_pos = matches[current_match_index]
# Replace the text
var line_text = editor.get_line(match_pos.x)
var new_line_text = line_text.substr(0, match_pos.y) + replace_text + line_text.substr(match_pos.y + search_text.length())
editor.set_line(match_pos.x, new_line_text)
# Update the document's content
editor.text_changed.emit()
# Refresh search
trigger_search()
replace_performed.emit()
func _on_replace_all_button_pressed() -> void:
if not is_instance_valid(editor):
return
var search_text = find_input.text
if search_text.is_empty() or matches.is_empty():
return
var replace_text = replace_input.text
# Determine which matches to replace
var matches_to_replace: Array[Vector2i]
if selection_only_checkbox.button_pressed and editor.has_selection():
matches_to_replace = get_matches_in_selection()
else:
matches_to_replace = matches.duplicate()
if matches_to_replace.is_empty():
return
# Sort matches in reverse order (to not affect positions of earlier matches)
matches_to_replace.sort_custom(func(a, b):
if a.x == b.x:
return a.y > b.y
return a.x > b.x
)
# Process replacements
var lines = editor.text.split("\n", false)
var replacements_count = 0
for match_pos in matches_to_replace:
# Replace text in the line
var line_text = lines[match_pos.x]
lines[match_pos.x] = line_text.substr(0, match_pos.y) + replace_text + line_text.substr(match_pos.y + search_text.length())
replacements_count += 1
# Only update if we made changes
if replacements_count > 0:
# Set the new text
editor.text = "\n".join(lines)
# Update the document's content
editor.text_changed.emit()
# Refresh search
trigger_search()
replace_all_performed.emit()
@@ -0,0 +1 @@
uid://bep06otwjntx1
+91
View File
@@ -0,0 +1,91 @@
@tool
class_name YAMLEditorMenuBar extends MenuBar
signal new_file
signal open_file
signal save_requested
signal save_as_requested
signal close_requested
signal undo_requested
signal redo_requested
signal cut_requested
signal copy_requested
signal paste_requested
signal select_all_requested
signal find_requested
signal find_next_requested
signal find_previous_requested
signal replace_requested
@export var file_menu: PopupMenu
@export var edit_menu: PopupMenu
@export var search_menu: PopupMenu
func _ready() -> void:
# Wait for UI to be ready
await get_tree().process_frame
# Set up the menu bar
_setup_menus()
func _setup_menus() -> void:
# File menu
file_menu.clear()
file_menu.add_item("New", 0, KEY_MASK_CTRL | KEY_N)
file_menu.add_item("Open...", 1, KEY_MASK_CTRL | KEY_O)
file_menu.add_separator()
file_menu.add_item("Save", 2, KEY_MASK_CTRL | KEY_S)
file_menu.add_item("Save As...", 3, KEY_MASK_CTRL | KEY_MASK_SHIFT | KEY_S)
file_menu.add_separator()
file_menu.add_item("Close", 4, KEY_MASK_CTRL | KEY_W)
# Edit menu
edit_menu.clear()
edit_menu.add_item("Undo", 0, KEY_MASK_CTRL | KEY_Z)
edit_menu.add_item("Redo", 1, KEY_MASK_CTRL | KEY_MASK_SHIFT | KEY_Z)
edit_menu.add_separator()
edit_menu.add_item("Cut", 2, KEY_MASK_CTRL | KEY_X)
edit_menu.add_item("Copy", 3, KEY_MASK_CTRL | KEY_C)
edit_menu.add_item("Paste", 4, KEY_MASK_CTRL | KEY_V)
edit_menu.add_separator()
edit_menu.add_item("Select All", 5, KEY_MASK_CTRL | KEY_A)
# Search menu
search_menu.clear()
search_menu.add_item("Find...", 0, KEY_MASK_CTRL | KEY_F)
search_menu.add_item("Find Next", 1, KEY_F3)
search_menu.add_item("Find Previous", 2, KEY_MASK_SHIFT | KEY_F3)
search_menu.add_separator()
search_menu.add_item("Replace...", 3, KEY_MASK_CTRL | KEY_R)
# Connect signals
file_menu.id_pressed.connect(_on_file_menu_id_pressed)
edit_menu.id_pressed.connect(_on_edit_menu_id_pressed)
search_menu.id_pressed.connect(_on_search_menu_id_pressed)
func _on_file_menu_id_pressed(id: int) -> void:
match id:
0: new_file.emit()
1: open_file.emit()
2: save_requested.emit()
3: save_as_requested.emit()
4: close_requested.emit()
func _on_edit_menu_id_pressed(id: int) -> void:
match id:
0: undo_requested.emit()
1: redo_requested.emit()
2: cut_requested.emit()
3: copy_requested.emit()
4: paste_requested.emit()
5: select_all_requested.emit()
func _on_search_menu_id_pressed(id: int) -> void:
match id:
0: find_requested.emit()
1: find_next_requested.emit()
2: find_previous_requested.emit()
3: replace_requested.emit()
+1
View File
@@ -0,0 +1 @@
uid://b35lyu1onhcoj
+109
View File
@@ -0,0 +1,109 @@
@tool
class_name YAMLEditorSessionManager extends Node
const CONFIG_PATH := "res://.godot/yaml_editor_session.cfg"
const CONFIG_SECTION := "yaml_editor"
const CONFIG_KEY_OPEN_FILES := "open_files"
const CONFIG_KEY_SPLIT_OFFSET := "split_offset"
const CONFIG_KEY_CURRENT_FILE := "current_file"
var file_manager: YAMLEditorDocumentManager
var file_system: YAMLFileSystem
var config: ConfigFile
var autosave_timer: Timer
var resizable_container: HSplitContainer
func _ready() -> void:
file_system = YAMLFileSystem.get_singleton()
config = ConfigFile.new()
# Setup autosave timer
autosave_timer = Timer.new()
add_child(autosave_timer)
autosave_timer.wait_time = 10.0 # Save session every 10 seconds
autosave_timer.one_shot = false
autosave_timer.autostart = true
autosave_timer.timeout.connect(_on_autosave_timer_timeout)
func setup(p_file_manager: YAMLEditorDocumentManager, p_resizable_container: HSplitContainer) -> void:
file_manager = p_file_manager
resizable_container = p_resizable_container
# Connect to signals
file_manager.document_changed.connect(_on_session_changed)
file_manager.document_created.connect(_on_session_changed)
file_manager.document_closed.connect(_on_session_changed)
resizable_container.dragged.connect(_on_split_dragged)
func _on_split_dragged(_offset: int) -> void:
# The split position has changed, save the session
_on_session_changed()
func save_session() -> void:
# Don't save anything if we have no files
if not is_instance_valid(file_manager):
return
var documents: Array = file_manager.get_open_documents()
# Create array of persistent file paths (skip untitled files)
var persistent_files: PackedStringArray = []
for document in documents:
if not document.is_untitled():
persistent_files.append(document.path)
# Get current file path
var current_path := ""
var current_document := file_manager.get_current_document()
if current_document and not current_document.is_untitled():
current_path = current_document.path
# Save to config file
config.set_value(CONFIG_SECTION, CONFIG_KEY_OPEN_FILES, persistent_files)
config.set_value(CONFIG_SECTION, CONFIG_KEY_CURRENT_FILE, current_path)
# Save the split offset
if is_instance_valid(resizable_container):
config.set_value(CONFIG_SECTION, CONFIG_KEY_SPLIT_OFFSET, resizable_container.split_offset)
var error := config.save(CONFIG_PATH)
if error != OK:
push_error("Failed to save YAML editor session: %s" % error_string(error))
func load_session() -> void:
var error := config.load(CONFIG_PATH)
if error != OK:
# No saved session or error loading it
if error != ERR_FILE_NOT_FOUND:
push_error("Failed to load YAML editor session: ", error_string(error))
return
# Get saved file paths
var file_paths: PackedStringArray = config.get_value(CONFIG_SECTION, CONFIG_KEY_OPEN_FILES, [])
# Open each file
for path in file_paths:
if file_system.file_exists(path):
file_manager.open_file(path)
# Set current file
var last_current: String = config.get_value(CONFIG_SECTION, CONFIG_KEY_CURRENT_FILE, "")
if not last_current.is_empty() and file_manager.has_document(last_current):
var document := file_manager.get_document(last_current)
file_manager.set_current_document(document)
# Restore split offset (deferred to ensure UI is ready)
call_deferred("_restore_split_offset")
func _restore_split_offset() -> void:
if is_instance_valid(resizable_container):
var saved_offset: int = config.get_value(CONFIG_SECTION, CONFIG_KEY_SPLIT_OFFSET, resizable_container.split_offset)
resizable_container.split_offset = saved_offset
func _on_session_changed(_document = null) -> void:
# Set a short timer to prevent saving too frequently during batch operations
autosave_timer.start()
func _on_autosave_timer_timeout() -> void:
save_session()
@@ -0,0 +1 @@
uid://c3hnw5vrco2iy
+64
View File
@@ -0,0 +1,64 @@
@tool
class_name YAMLEditorStatusBar extends HBoxContainer
@export var editor: YAMLCodeEditor
@export var status_label: Label
@export var zoom_button: Button
@export var line_column_label: Label
var zoom_popup_menu: PopupMenu
func _ready() -> void:
# Zoom popup menu
zoom_popup_menu = PopupMenu.new()
add_child(zoom_popup_menu)
zoom_popup_menu.add_item("25 %", 0)
zoom_popup_menu.add_item("50 %", 1)
zoom_popup_menu.add_item("75 %", 2)
zoom_popup_menu.add_item("100 %", 3)
zoom_popup_menu.add_item("150 %", 4)
zoom_popup_menu.add_item("200 %", 5)
zoom_popup_menu.add_item("300 %", 6)
zoom_popup_menu.id_pressed.connect(_on_zoom_popup_menu_id_pressed)
zoom_button.pressed.connect(
func():
var global_rect := Rect2(get_global_mouse_position(), Vector2.ZERO)
zoom_popup_menu.popup_on_parent(global_rect)
)
status_label.set("theme_override_constants/use_pixel_snap", true)
func _on_zoom_popup_menu_id_pressed(idx: int) -> void:
match idx:
0: editor.set_zoom(0.25)
1: editor.set_zoom(0.5)
2: editor.set_zoom(0.75)
3: editor.set_zoom(1.0)
4: editor.set_zoom(1.5)
5: editor.set_zoom(2.0)
6: editor.set_zoom(3.0)
zoom_popup_menu.hide()
func set_status(text: String, color := Color.WHITE) -> void:
status_label.text = text
status_label.modulate = color
func set_line_column(line_column: Array[int]) -> void:
var line := line_column[0]
var col := line_column[1]
line_column_label.text = "%d : %d" % [line, col]
func set_zoom_level(level: float) -> void:
zoom_button.text = str(int(level * 100)) + " %"
func set_validation_result(result: YAMLResult) -> void:
if !result.has_error():
return set_status("")
var error := result.get_error_message()
var line := result.get_error_line()
var col := result.get_error_column()
var error_text := "Error at (%d, %d): %s" % [line, col, error] if line >= 0 else "Error: %s" % error
var error_color: Color = EditorInterface.get_editor_settings().get_setting("text_editor/theme/highlighting/brace_mismatch_color")
set_status(error_text, error_color)
+1
View File
@@ -0,0 +1 @@
uid://bkanguis54ea3
@@ -0,0 +1,21 @@
@tool
class_name YAMLEditorSyntaxHighlighter extends EditorSyntaxHighlighter
var cache: Dictionary = {}
var syntax_parser := YAMLSyntaxParser.new()
var color_provider := YAMLSyntaxParser.ColorProvider.new()
func clear_highlighting_cache() -> void:
cache.clear()
func _get_line_syntax_highlighting(line: int) -> Dictionary:
var text: String = get_text_edit().get_line(line)
if text in cache:
return cache[text]
color_provider.update_theme()
cache[text] = _highlight_line(text)
return cache[text]
func _highlight_line(text: String) -> Dictionary:
return syntax_parser.highlight_line(text, color_provider)
@@ -0,0 +1 @@
uid://c0q2685eddg35
@@ -0,0 +1,20 @@
class_name YAMLSyntaxHighlighter extends SyntaxHighlighter
var cache: Dictionary = {}
var syntax_parser := YAMLSyntaxParser.new()
var color_provider := YAMLSyntaxParser.ColorProvider.new()
func clear_highlighting_cache() -> void:
cache.clear()
func _get_line_syntax_highlighting(line: int) -> Dictionary:
var text: String = get_text_edit().get_line(line)
if text in cache:
return cache[text]
color_provider.update_theme()
cache[text] = _highlight_line(text)
return cache[text]
func _highlight_line(text: String) -> Dictionary:
return syntax_parser.highlight_line(text, color_provider)
@@ -0,0 +1 @@
uid://8yvnf7wlu3ud
@@ -0,0 +1,384 @@
@tool
class_name YAMLSyntaxParser extends RefCounted
## Token types
enum TokenType {
TEXT, # For keys only
COMMENT, # Comments
SYMBOL, # Structural elements like :, -, >, |, &, *, [, ], {, }
STRING, # String values (default for unmatched values)
NUMBER, # Numeric values
KEYWORD, # Booleans, null, merge keys, tags
DOCUMENT_SEPARATOR, # New document separator
}
var re_patterns := {
"merge_key": RegEx.create_from_string("^\\s*<<:\\s*\\*[^\\s]+"),
"multiline_indicator": RegEx.create_from_string("(>|\\|-?)\\s*$"),
"array_item": RegEx.create_from_string("^(\\s*-(?:\\s*-)*\\s*)(.*)$"),
"key_value": RegEx.create_from_string("^\\s*([^:]+):(.*)$"),
# Scalar patterns
"quoted_string": RegEx.create_from_string("^(['\"])(?:\\\\.|[^\\\\])*\\1$"),
"number": RegEx.create_from_string("^(?:0[xX][0-9a-fA-F]+|0[oO][0-7]+|0[bB][0-1]+|[-+]?(?:\\d+\\.?\\d*|\\.\\d+)(?:[eE][-+]?\\d+)?)$"),
"boolean": RegEx.create_from_string("^(true|false)$"),
"nullish": RegEx.create_from_string("^(null|~)$"),
"special": RegEx.create_from_string("^(\\.inf|\\.nan)$"),
# YAML functionality
"anchor": RegEx.create_from_string("^\\s*&([^\\s]+)"),
"alias": RegEx.create_from_string("^\\s*\\*([^\\s]+)"),
"tag": RegEx.create_from_string("!(?:![\\w\\-\\.]+|[^\\s\\[\\]{}'\"`]+)"),
"top_level_tag": RegEx.create_from_string("^\\s*(!(?:![\\w\\-\\.]+|[^\\s\\[\\]{}'\"`]+))\\s*(.*)$"),
"document_separator": RegEx.create_from_string("^---$")
}
class ParserState:
var in_string: bool = false
var string_char: String = ""
var stack: Array = [] # For nested flow collections
var token_start: int = -1
var colors: Dictionary = {}
func push(char: String) -> void:
stack.push_back(char)
func pop() -> String:
return stack.pop_back() if not stack.is_empty() else ""
func peek() -> String:
return stack.back() if not stack.is_empty() else ""
## Provides colors to the syntax highlighter
class ColorProvider:
# Default theme colors
var theme: Dictionary = {
"text": Color(0.8025, 0.81, 0.8225, 1),
"comment": Color(0.8025, 0.81, 0.8225, 0.5),
"symbol": Color(0.67, 0.79, 1, 1),
"string": Color(1, 0.93, 0.63, 1),
"number": Color(0.63, 1, 0.88, 1),
"keyword": Color(1, 0.44, 0.52, 1),
"document_separator": Color(0.8025, 0.81, 0.8225, 0.5)
}
func _init():
update_theme()
## Updates the theme colors from Godot Editor settings
func update_theme() -> void:
# Only inside the Godot Editor
if !Engine.is_editor_hint():
return
# Read theme from editor settings
var settings = EditorInterface.get_editor_settings()
theme = {
"text": settings.get_setting("text_editor/theme/highlighting/text_color"),
"comment": settings.get_setting("text_editor/theme/highlighting/comment_color"),
"symbol": settings.get_setting("text_editor/theme/highlighting/symbol_color"),
"string": settings.get_setting("text_editor/theme/highlighting/string_color"),
"number": settings.get_setting("text_editor/theme/highlighting/number_color"),
"keyword": settings.get_setting("text_editor/theme/highlighting/keyword_color"),
"document_separator": settings.get_setting("text_editor/theme/highlighting/comment_color"),
}
## Get color for tokens
func get_color_for_type(type: TokenType) -> Color:
match type:
YAMLSyntaxParser.TokenType.TEXT: return theme.text
YAMLSyntaxParser.TokenType.COMMENT: return theme.comment
YAMLSyntaxParser.TokenType.SYMBOL: return theme.symbol
YAMLSyntaxParser.TokenType.STRING: return theme.string
YAMLSyntaxParser.TokenType.NUMBER: return theme.number
YAMLSyntaxParser.TokenType.KEYWORD: return theme.keyword
YAMLSyntaxParser.TokenType.DOCUMENT_SEPARATOR: return theme.document_separator
_: return theme.string # Default fallback is string color
## Highlight a line of YAML
func highlight_line(text: String, color_provider: ColorProvider = ColorProvider.new()) -> Dictionary:
var comment_pos := _find_comment_start(text)
var content := text if comment_pos == -1 else text.substr(0, comment_pos).rstrip(" \t")
var colors := {}
if content.strip_edges():
colors = _highlight_line_content(content, color_provider)
if comment_pos != -1:
colors[comment_pos] = {"color": color_provider.get_color_for_type(TokenType.COMMENT)}
return _sort_colors(colors)
# Find the beginning position of a comment
func _find_comment_start(text: String) -> int:
var in_string := false
var string_char := ""
for i in range(text.length()):
var char := text[i]
if not in_string:
if char in ['"', "'"] and (i == 0 or text[i - 1] != '\\'):
in_string = true
string_char = char
elif char == '#' and (i == 0 or text[i - 1] == ' ' or text[i - 1] == '\t'):
return i
else:
if char == string_char and (i == 0 or text[i - 1] != '\\'):
in_string = false
string_char = ""
return -1
# Highlight line content
func _highlight_line_content(text: String, color_provider: ColorProvider) -> Dictionary:
# Handle document separator
var separator_match: RegExMatch = re_patterns.document_separator.search(text)
if separator_match:
return {0: {"color": color_provider.get_color_for_type(TokenType.DOCUMENT_SEPARATOR)}}
# Handle merge keys
var merge_match: RegExMatch = re_patterns.merge_key.search(text)
if merge_match:
return {
merge_match.get_start(0): {"color": color_provider.get_color_for_type(TokenType.KEYWORD)}
}
# Check for top-level tags
var tag_match: RegExMatch = re_patterns.top_level_tag.search(text)
if tag_match:
var colors := {}
# Color just the tag part as keyword (red)
_add_color(color_provider, colors, tag_match.get_start(1), tag_match.get_end(1), TokenType.KEYWORD)
# Process any remaining content after the tag
var remaining = tag_match.get_string(2).strip_edges()
if remaining:
var remaining_start = text.find(remaining, tag_match.get_end(1))
if remaining_start != -1:
if remaining.begins_with("{") or remaining.begins_with("["):
colors.merge(_parse_flow_style(color_provider, remaining, remaining_start))
else:
_add_scalar_color(color_provider, colors, remaining, remaining_start)
return colors
# Handle array items
var array_match: RegExMatch = re_patterns.array_item.search(text)
if array_match:
var colors := {}
# Color the entire dash section as symbols
_add_color(color_provider, colors, array_match.get_start(1), array_match.get_end(1), TokenType.SYMBOL)
# Process the content after the dashes
var content: String = array_match.get_string(2).strip_edges()
if content:
var content_start: int = array_match.get_start(2)
if content.begins_with("[") or content.begins_with("{"):
colors.merge(_parse_flow_style(color_provider, content, content_start))
else:
_add_scalar_color(color_provider, colors, content, content_start)
return colors
# Handle regular key-value pairs
var key_value_match: RegExMatch = re_patterns.key_value.search(text)
if key_value_match:
return _parse_key_value(color_provider, text, key_value_match)
# Handle flow-style collections at the root level
if "[" in text or "{" in text:
return _parse_flow_style(color_provider, text, 0)
# Handle multi-line string indicators
var multiline_match: RegExMatch = re_patterns.multiline_indicator.search(text)
if multiline_match:
var colors := {}
# Color the indicator (> or |) as symbol
_add_color(color_provider, colors, multiline_match.get_start(1), multiline_match.get_end(1), TokenType.SYMBOL)
return colors
# Default case: treat as string content (for multi-line string content)
if text.strip_edges():
return {0: {"color": color_provider.get_color_for_type(TokenType.STRING)}}
return {}
# Parse flow collections
func _parse_flow_style(color_provider: ColorProvider, text: String, offset: int) -> Dictionary:
var state := ParserState.new()
var pos := 0
while pos < text.length():
var char := text[pos]
# Handle string literals
if char in ['"', "'"] and (pos == 0 or text[pos - 1] != '\\'):
if not state.in_string:
state.in_string = true
state.string_char = char
state.token_start = pos
elif char == state.string_char:
state.in_string = false
_add_color(color_provider, state.colors, offset + state.token_start, offset + pos + 1, TokenType.STRING)
state.token_start = -1
# Handle flow collection brackets when not in string
elif not state.in_string:
if char in ['[', '{']:
state.push(char)
_add_color(color_provider, state.colors, offset + pos, offset + pos + 1, TokenType.SYMBOL)
state.token_start = pos + 1
elif char in [']', '}']:
var matching := '[' if char == ']' else '{'
if state.peek() == matching:
state.pop()
if state.token_start != -1:
var token := text.substr(state.token_start, pos - state.token_start).strip_edges()
if token:
_add_scalar_color(color_provider, state.colors, token, offset + state.token_start)
_add_color(color_provider, state.colors, offset + pos, offset + pos + 1, TokenType.SYMBOL)
state.token_start = -1
elif char in [':', ',']:
if state.token_start != -1:
var token := text.substr(state.token_start, pos - state.token_start).strip_edges()
if token:
if char == ':':
# All map keys should be text colored, regardless of content
_add_color(color_provider, state.colors, offset + state.token_start, offset + pos, TokenType.TEXT)
else:
_add_scalar_color(color_provider, state.colors, token, offset + state.token_start)
_add_color(color_provider, state.colors, offset + pos, offset + pos + 1, TokenType.SYMBOL)
state.token_start = pos + 1
elif char != ' ' and state.token_start == -1:
state.token_start = pos
pos += 1
# Handle any remaining token
if state.token_start != -1 and state.token_start < pos:
var token := text.substr(state.token_start, pos - state.token_start).strip_edges()
if token:
# Check if this is a key in a map context
if not state.stack.is_empty() and state.stack.back() == '{' and ':' in text.substr(pos):
_add_color(color_provider, state.colors, offset + state.token_start, offset + pos, TokenType.TEXT)
else:
_add_scalar_color(color_provider, state.colors, token, offset + state.token_start)
return state.colors
# Parse dictionary key and value
func _parse_key_value(color_provider: ColorProvider, text: String, match: RegExMatch) -> Dictionary:
var colors := {}
# Color the key
_add_color(color_provider, colors, match.get_start(1), match.get_end(1), TokenType.TEXT)
# Color the colon
_add_color(color_provider,colors, match.get_end(1), match.get_end(1) + 1, TokenType.SYMBOL)
# Get and process the value if present
var value := match.get_string(2).strip_edges()
if value:
var value_start := text.find(value, match.get_end(1))
if value_start != -1:
# First check for and handle any tags
var tag_match: RegExMatch = re_patterns.tag.search(value)
if tag_match:
_add_color(color_provider, colors, value_start + tag_match.get_start(0),
value_start + tag_match.get_end(0), TokenType.KEYWORD)
# Get remaining content after tag
var after_tag := value.substr(tag_match.get_end(0)).strip_edges()
if after_tag:
var after_tag_start = text.find(after_tag, value_start + tag_match.get_end(0))
if after_tag_start != -1:
# Now check for multiline indicator in remaining content
var indicator_match: RegExMatch = re_patterns.multiline_indicator.search(after_tag)
if indicator_match:
_add_color(color_provider, colors, after_tag_start + indicator_match.get_start(1), after_tag_start + indicator_match.get_end(1), TokenType.SYMBOL)
elif after_tag.begins_with("{") or after_tag.begins_with("["):
# Process flow style collections after the tag
colors.merge(_parse_flow_style(color_provider, after_tag, after_tag_start))
else:
# Process normal scalar after the tag
_add_scalar_color(color_provider, colors, after_tag, after_tag_start)
return colors
# If no tag, check for multiline indicator in full value
var indicator_match: RegExMatch = re_patterns.multiline_indicator.search(value)
if indicator_match:
_add_color(color_provider, colors, value_start + indicator_match.get_start(1), value_start + indicator_match.get_end(1), TokenType.SYMBOL)
elif value.begins_with("[") or value.begins_with("{"):
colors.merge(_parse_flow_style(color_provider, value, value_start))
else:
_add_scalar_color(color_provider, colors, value, value_start)
return colors
# Colors for scalar values
func _add_scalar_color(color_provider: ColorProvider, colors: Dictionary, token: String, start_index: int) -> void:
# Handle empty or whitespace-only tokens
token = token.strip_edges()
if token.is_empty():
return
# Check for quoted strings first
if re_patterns.quoted_string.search(token):
_add_color(color_provider, colors, start_index, start_index + token.length(), TokenType.STRING)
return # Important: return early to prevent parsing tags inside strings
# Check for tags
var tag_match: RegExMatch = re_patterns.tag.search(token)
if tag_match:
var tag_start := tag_match.get_start(0)
var tag_end := tag_match.get_end(0)
# Only color the tag portion
_add_color(color_provider, colors, start_index + tag_start, start_index + tag_end, TokenType.KEYWORD)
# Process any remaining content after the tag
if tag_end < token.length():
var remaining := token.substr(tag_end).strip_edges()
if remaining:
var remaining_start = start_index + token.find(remaining, tag_end)
if remaining_start != -1:
if remaining.begins_with("{") or remaining.begins_with("["):
colors.merge(_parse_flow_style(color_provider, remaining, remaining_start))
else:
# Apply appropriate coloring for the remaining content
if re_patterns.number.search(remaining):
_add_color(color_provider, colors, remaining_start, remaining_start + remaining.length(), TokenType.NUMBER)
elif re_patterns.boolean.search(remaining) or re_patterns.nullish.search(remaining) or re_patterns.special.search(remaining):
_add_color(color_provider, colors, remaining_start, remaining_start + remaining.length(), TokenType.KEYWORD)
else:
_add_color(color_provider, colors, remaining_start, remaining_start + remaining.length(), TokenType.STRING)
return
# Rest of the scalar checks for non-tag content
elif re_patterns.number.search(token):
_add_color(color_provider, colors, start_index, start_index + token.length(), TokenType.NUMBER)
elif re_patterns.boolean.search(token) or re_patterns.nullish.search(token) or re_patterns.special.search(token):
_add_color(color_provider, colors, start_index, start_index + token.length(), TokenType.KEYWORD)
elif re_patterns.anchor.search(token) or re_patterns.alias.search(token):
_add_color(color_provider, colors, start_index, start_index + token.length(), TokenType.SYMBOL)
else:
# Default fallback is string color
_add_color(color_provider, colors, start_index, start_index + token.length(), TokenType.STRING)
# Add color for a type
func _add_color(color_provider: ColorProvider, colors: Dictionary, start: int, end: int, type: TokenType) -> void:
colors[start] = {"color": color_provider.get_color_for_type(type)}
# Sort the colors dictionary by index
func _sort_colors(colors: Dictionary) -> Dictionary:
# Get all indices as an array
var indices := colors.keys()
indices.sort() # Sort indices in ascending order
# Create new dictionary with sorted indices
var sorted_colors := {}
for idx in indices:
sorted_colors[idx] = colors[idx]
return sorted_colors
@@ -0,0 +1 @@
uid://dco608883k7yl
+109
View File
@@ -0,0 +1,109 @@
@tool
class_name YAMLEditorValidator extends Node
signal validation_completed(document)
var _thread: Thread
var _is_validating: bool = false
var _pending_validation: bool = false
var _validation_queue: Array = []
var code_editor: YAMLCodeEditor
var validation_timer: Timer
var file_system: YAMLFileSystem
var file_manager: YAMLEditorDocumentManager
func _ready() -> void:
file_system = YAMLFileSystem.get_singleton()
# Create validation timer
validation_timer = Timer.new()
add_child(validation_timer)
validation_timer.one_shot = true
validation_timer.wait_time = 0.5 # 500ms delay
validation_timer.timeout.connect(_on_validation_timer_timeout)
func _exit_tree() -> void:
if _thread and _thread.is_started():
_thread.wait_to_finish()
func setup(p_code_editor: YAMLCodeEditor, p_file_manager: YAMLEditorDocumentManager) -> void:
code_editor = p_code_editor
file_manager = p_file_manager
# Connect to code editor changes
code_editor.validation_requested.connect(_on_validation_requested)
# Connect to document changes
file_manager.document_changed.connect(_on_document_changed)
file_manager.document_created.connect(_on_document_created)
func _on_validation_requested() -> void:
# Reset and start the validation timer
validation_timer.stop()
validation_timer.start()
func _on_validation_timer_timeout() -> void:
var document = file_manager.get_current_document()
if document:
validate_document(document)
func _on_document_changed(document: YAMLEditorDocument) -> void:
# Show any existing validation results
if document.validation_result:
validation_completed.emit(document)
# Run validation if no results exist or document has errors
if document.validation_result == null or document.has_error():
validate_document(document)
func _on_document_created(document: YAMLEditorDocument) -> void:
# Validate new document
validate_document(document)
func validate_document(document: YAMLEditorDocument) -> void:
if document == null:
return
if _is_validating:
# Add to validation queue
if not _validation_queue.has(document):
_validation_queue.append(document)
return
_is_validating = true
if _thread and _thread.is_started():
_thread.wait_to_finish()
_thread = Thread.new()
_thread.start(_validation_thread_function.bind(document))
func _validation_thread_function(document: YAMLEditorDocument) -> void:
# YAML validation is thread-safe
var result = YAML.validate_syntax(document.content)
# Update document on main thread
call_deferred("_finish_validation", document, result)
func _finish_validation(document: YAMLEditorDocument, result: YAMLResult) -> void:
# Update document with validation result
document.set_validation_result(result)
# Emit signal
validation_completed.emit(document)
# Process any pending validations
_is_validating = false
if not _validation_queue.is_empty():
var next_document = _validation_queue.pop_front()
validate_document(next_document)
func mark_error_in_editor(line: int, message: String) -> void:
if is_instance_valid(code_editor):
code_editor.mark_error_line(line, message)
func clear_errors_in_editor() -> void:
if is_instance_valid(code_editor):
code_editor.clear_error_indicators()
+1
View File
@@ -0,0 +1 @@
uid://dbtbggxu57ydy
+350
View File
@@ -0,0 +1,350 @@
@tool
class_name YAMLEditor extends Control
# Components
var file_manager: YAMLEditorDocumentManager
var validator: YAMLEditorValidator
var session_manager: YAMLEditorSessionManager
# File system singleton
var file_system: YAMLFileSystem
# UI references
@export var menu_bar: YAMLEditorMenuBar
@export var file_list: YAMLEditorFileList
@export var resizable_container: HSplitContainer
@export var code_edit: YAMLCodeEditor
@export var status_panel: YAMLEditorStatusBar
@export var find_replace_panel: YAMLEditorFindReplaceBar
func _ready() -> void:
# Get reference to file system singleton first
file_system = YAMLFileSystem.get_singleton()
# Initialize components
file_manager = YAMLEditorDocumentManager.new(resizable_container)
add_child(file_manager)
validator = YAMLEditorValidator.new()
add_child(validator)
session_manager = YAMLEditorSessionManager.new()
add_child(session_manager)
# Wait for UI to be ready
await get_tree().process_frame
# Set up components
file_manager.setup(file_list, code_edit)
validator.setup(code_edit, file_manager)
session_manager.setup(file_manager, resizable_container)
# Connect menu signals for file operations
menu_bar.new_file.connect(_on_new_button_pressed)
menu_bar.open_file.connect(_on_open_button_pressed)
menu_bar.save_requested.connect(_on_save_button_pressed)
menu_bar.save_as_requested.connect(_on_save_as_button_pressed)
menu_bar.close_requested.connect(_on_close_current_file)
# Connect menu signals for edit options
menu_bar.undo_requested.connect(_on_undo_requested)
menu_bar.redo_requested.connect(_on_redo_requested)
menu_bar.cut_requested.connect(_on_cut_requested)
menu_bar.copy_requested.connect(_on_copy_requested)
menu_bar.paste_requested.connect(_on_paste_requested)
menu_bar.select_all_requested.connect(_on_select_all_requested)
# Connect menu signals for search
menu_bar.find_requested.connect(_on_find_requested)
menu_bar.find_next_requested.connect(_on_find_next_requested)
menu_bar.find_previous_requested.connect(_on_find_previous_requested)
menu_bar.replace_requested.connect(_on_replace_requested)
# Connect code editor signals
code_edit.content_changed.connect(_on_content_changed)
code_edit.save_requested.connect(_on_save_button_pressed)
code_edit.close_requested.connect(_on_close_current_file)
code_edit.undo_requested.connect(_on_undo_requested)
code_edit.redo_requested.connect(_on_redo_requested)
code_edit.caret_changed.connect(_on_caret_changed)
code_edit.zoom_changed.connect(_on_zoom_changed) # Connect to new zoom signal
# Connect file manager signals
file_manager.document_changed.connect(_on_document_changed)
# Connect validation signals
validator.validation_completed.connect(_on_validation_completed)
# Set initial zoom text
_on_zoom_changed(code_edit.zoom_level)
# Setup the find and replace panels
find_replace_panel.replace_performed.connect(_on_replace_performed)
find_replace_panel.replace_all_performed.connect(_on_replace_all_performed)
# Load previous session
session_manager.load_session()
func _input(event):
if event is InputEventKey and event.keycode == KEY_ESCAPE and event.pressed:
if find_replace_panel.visible:
find_replace_panel.hide_panel()
get_viewport().set_input_as_handled()
func _unhandled_key_input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed:
match event.get_keycode_with_modifiers():
KEY_MASK_CTRL | KEY_F:
_on_find_requested()
get_viewport().set_input_as_handled()
KEY_MASK_CTRL | KEY_R:
_on_replace_requested()
get_viewport().set_input_as_handled()
KEY_F3:
_on_find_next_requested()
get_viewport().set_input_as_handled()
KEY_MASK_SHIFT | KEY_F3:
_on_find_previous_requested()
get_viewport().set_input_as_handled()
func _on_zoom_changed(new_zoom_level: float) -> void:
status_panel.set_zoom_level(new_zoom_level)
func _on_new_button_pressed() -> void:
file_manager.new_file()
func _on_open_button_pressed() -> void:
var file_dialog := EditorFileDialog.new()
file_dialog.file_mode = EditorFileDialog.FILE_MODE_OPEN_FILE
file_dialog.access = EditorFileDialog.ACCESS_FILESYSTEM
file_dialog.add_filter("*.yaml;YAML Files")
file_dialog.add_filter("*.yml;YML Files")
file_dialog.title = "Open YAML File"
file_dialog.file_selected.connect(
func(path):
file_manager.open_file(path)
file_dialog.queue_free()
)
file_dialog.canceled.connect(func(): file_dialog.queue_free())
add_child(file_dialog)
file_dialog.popup_centered_ratio(0.7)
func _on_save_button_pressed() -> void:
var document := file_manager.get_current_document()
if not document:
return
if document.is_untitled():
_on_save_as_button_pressed()
else:
file_manager.save_document(document)
func _on_save_as_button_pressed() -> void:
var document := file_manager.get_current_document()
if not document:
return
var file_dialog := EditorFileDialog.new()
file_dialog.file_mode = EditorFileDialog.FILE_MODE_SAVE_FILE
file_dialog.access = EditorFileDialog.ACCESS_FILESYSTEM
file_dialog.add_filter("*.yaml;YAML Files")
file_dialog.add_filter("*.yml;YML Files")
file_dialog.title = "Save YAML File As"
if not document.is_untitled():
file_dialog.current_path = document.path
file_dialog.file_selected.connect(
func(path):
file_manager.save_document_as(document, path)
file_dialog.queue_free()
)
file_dialog.canceled.connect(func(): file_dialog.queue_free())
add_child(file_dialog)
file_dialog.popup_centered_ratio(0.7)
func _on_content_changed() -> void:
var document := file_manager.get_current_document()
if not document:
return
# Update document content
file_manager.update_document_content(document, code_edit.text)
# Request validation
validator.validate_document(document)
func _on_close_current_file() -> void:
var document := file_manager.get_current_document()
if document:
file_manager.close_document(document)
func _on_undo_requested() -> void:
var document := file_manager.get_current_document()
if not document:
return
var state := document.undo()
if not state:
return
code_edit.set_text_and_preserve_state(document.content)
# Optionally restore caret position if needed
if state.caret_line > 0 and state.caret_column > 0:
if state.caret_line < code_edit.get_line_count():
code_edit.set_caret_line(state.caret_line)
if state.caret_column <= code_edit.get_line(state.caret_line).length():
code_edit.set_caret_column(state.caret_column)
# Validate after undo
validator.validate_document(document)
# And re-trigger search if we did undo
if find_replace_panel.visible:
find_replace_panel.trigger_search()
func _on_redo_requested() -> void:
var document := file_manager.get_current_document()
if not document:
return
var state := document.redo()
if not state:
return
code_edit.set_text_and_preserve_state(document.content)
# Optionally restore caret position if needed
if state.caret_line > 0 and state.caret_column > 0:
if state.caret_line < code_edit.get_line_count():
code_edit.set_caret_line(state.caret_line)
if state.caret_column <= code_edit.get_line(state.caret_line).length():
code_edit.set_caret_column(state.caret_column)
# Validate after redo
validator.validate_document(document)
# And re-trigger search if we did redo
if find_replace_panel.visible:
find_replace_panel.trigger_search()
func _on_cut_requested() -> void:
code_edit.cut_selection()
func _on_copy_requested() -> void:
code_edit.copy_selection()
func _on_paste_requested() -> void:
code_edit.paste_clipboard()
func _on_select_all_requested() -> void:
code_edit.select_all()
# Search-related methods
func _on_find_requested() -> void:
find_replace_panel.show_find_panel()
# Hide replace panel if we request just search
if find_replace_panel.replace_panel_visible:
find_replace_panel.replace_panel_visible = false
func _on_replace_requested() -> void:
find_replace_panel.show_replace_panel()
func _on_find_next_requested() -> void:
if find_replace_panel and find_replace_panel.visible:
find_replace_panel.find_next()
else:
_on_find_requested()
func _on_find_previous_requested() -> void:
if find_replace_panel and find_replace_panel.visible:
find_replace_panel.find_previous()
else:
_on_find_requested()
func _on_replace_performed() -> void:
# After a replace, request validation
code_edit.validation_requested.emit()
# Update the current document
_on_content_changed()
func _on_replace_all_performed() -> void:
# After replace all, request validation
code_edit.validation_requested.emit()
# Update the current document
_on_content_changed()
# Show a message in the status bar
if find_replace_panel.visible and find_replace_panel.replace_panel.visible:
status_panel.set_status("Replacement complete", Color.GREEN)
# Clear the status after a delay
if get_tree():
await get_tree().create_timer(2.0).timeout
status_panel.set_status("")
func _on_document_changed(document: YAMLEditorDocument) -> void:
# Update status panel with document info
_update_line_col_label()
# Show any validation errors
if document.has_error():
_display_validation_error(document)
else:
status_panel.set_status("")
validator.clear_errors_in_editor()
# Re-trigger search
if find_replace_panel.visible:
find_replace_panel.trigger_search()
func _on_caret_changed() -> void:
status_panel.set_line_column(code_edit.get_current_line_col_info())
func _on_validation_completed(document: YAMLEditorDocument) -> void:
if document != file_manager.get_current_document():
return
if document.has_error():
_display_validation_error(document)
else:
status_panel.set_status("")
validator.clear_errors_in_editor()
func _display_validation_error(document: YAMLEditorDocument) -> void:
status_panel.set_validation_result(document.validation_result)
var result := document.validation_result
if not result.has_error():
return
# Mark error line in editor if possible
var error := result.get_error_message()
var line := result.get_error_line()
if line >= 0:
validator.mark_error_in_editor(line - 1, error) # Convert to 0-based line
func _has_unsaved_changes() -> bool:
return file_manager.has_unsaved_changes()
func get_open_files() -> Array:
return file_manager.get_open_paths()
func handle_filesystem_change() -> void:
file_manager.handle_filesystem_change()
func _notification(what):
if what == NOTIFICATION_WM_CLOSE_REQUEST:
# Save session when editor is closing
session_manager.save_session()
func _update_line_col_label() -> void:
# Allow one frame to pass to ensure the UI is updated
if get_tree():
await get_tree().process_frame
_on_caret_changed()
+1
View File
@@ -0,0 +1 @@
uid://bbbai2ba18l5f
+400
View File
@@ -0,0 +1,400 @@
[gd_scene load_steps=27 format=3 uid="uid://b7wsl0ss24hvb"]
[ext_resource type="Script" uid="uid://bbbai2ba18l5f" path="res://addons/yaml/editor/yaml_editor.gd" id="1_jqpkv"]
[ext_resource type="Script" uid="uid://c0q2685eddg35" path="res://addons/yaml/editor/syntax_highlighting/editor_syntax_highlighter.gd" id="2_0xmrl"]
[ext_resource type="Script" uid="uid://drkui6da5o1ou" path="res://addons/yaml/editor/code_editor.gd" id="2_lgcdy"]
[ext_resource type="Script" uid="uid://c15odacm31d03" path="res://addons/yaml/editor/file_list.gd" id="3_ufmqr"]
[ext_resource type="Script" uid="uid://b35lyu1onhcoj" path="res://addons/yaml/editor/menu_bar.gd" id="5_bqqrn"]
[ext_resource type="Script" uid="uid://bkanguis54ea3" path="res://addons/yaml/editor/status_bar.gd" id="6_rk03l"]
[ext_resource type="Script" uid="uid://bep06otwjntx1" path="res://addons/yaml/editor/find_replace_bar.gd" id="7_vo7uj"]
[sub_resource type="Image" id="Image_cjchp"]
data = {
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 0, 0, 0, 0, 255, 92, 92, 127, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 0, 0, 0, 0, 255, 93, 93, 255, 255, 92, 92, 127, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 0, 0, 0, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 92, 92, 127, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 0, 0, 0, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 92, 92, 127, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 93, 93, 231, 255, 90, 90, 54, 255, 94, 94, 57, 255, 93, 93, 233, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 231, 255, 90, 90, 54, 255, 94, 94, 57, 255, 93, 93, 233, 255, 93, 93, 255, 255, 93, 93, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 91, 91, 42, 0, 0, 0, 0, 0, 0, 0, 0, 255, 91, 91, 42, 255, 93, 93, 233, 255, 92, 92, 232, 255, 93, 93, 41, 0, 0, 0, 0, 0, 0, 0, 0, 255, 91, 91, 42, 255, 93, 93, 233, 255, 92, 92, 232, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 91, 91, 45, 255, 93, 93, 44, 0, 0, 0, 0, 255, 91, 91, 42, 255, 91, 91, 42, 0, 0, 0, 0, 255, 91, 91, 45, 255, 93, 93, 44, 0, 0, 0, 0, 255, 91, 91, 42, 255, 91, 91, 42, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 91, 91, 45, 255, 92, 92, 235, 255, 92, 92, 234, 255, 89, 89, 43, 0, 0, 0, 0, 0, 0, 0, 0, 255, 91, 91, 45, 255, 92, 92, 235, 255, 92, 92, 234, 255, 89, 89, 43, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 92, 92, 235, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 233, 255, 91, 91, 59, 255, 92, 92, 61, 255, 92, 92, 235, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 233, 255, 91, 91, 59, 255, 92, 92, 61, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
"format": "RGBA8",
"height": 16,
"mipmaps": false,
"width": 16
}
[sub_resource type="ImageTexture" id="ImageTexture_oh2kv"]
image = SubResource("Image_cjchp")
[sub_resource type="EditorSyntaxHighlighter" id="EditorSyntaxHighlighter_68y40"]
script = ExtResource("2_0xmrl")
[sub_resource type="SystemFont" id="SystemFont_k3025"]
subpixel_positioning = 0
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_42q8o"]
[sub_resource type="SystemFont" id="SystemFont_k8p4n"]
font_names = PackedStringArray("Monospace")
subpixel_positioning = 0
[sub_resource type="SystemFont" id="SystemFont_wntl2"]
fallbacks = Array[Font]([SubResource("SystemFont_k8p4n")])
font_names = PackedStringArray("JetBrains Mono")
subpixel_positioning = 0
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_i8tps"]
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_r4bqd"]
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_3ksor"]
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_33vym"]
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_jwbjn"]
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_xhoyn"]
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_vnqw7"]
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_1i5or"]
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_vlfg7"]
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_ki2vh"]
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_ke1f7"]
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_v3fgn"]
[node name="YamlEditor" type="Control" node_paths=PackedStringArray("menu_bar", "file_list", "resizable_container", "code_edit", "status_panel", "find_replace_panel")]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
size_flags_horizontal = 3
size_flags_vertical = 3
script = ExtResource("1_jqpkv")
menu_bar = NodePath("MainMargin/MainVBox/MenuBarMargin/MenuBar")
file_list = NodePath("MainMargin/MainVBox/ResizableContainer/FileList")
resizable_container = NodePath("MainMargin/MainVBox/ResizableContainer")
code_edit = NodePath("MainMargin/MainVBox/ResizableContainer/EditorPanel/YAMLCodeEdit")
status_panel = NodePath("MainMargin/MainVBox/ResizableContainer/EditorPanel/BottomToolbar/StatusBarMargin/StatusBar")
find_replace_panel = NodePath("MainMargin/MainVBox/ResizableContainer/EditorPanel/BottomToolbar/FindReplaceBar")
[node name="MainMargin" type="MarginContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/margin_left = 4
theme_override_constants/margin_top = 1
theme_override_constants/margin_right = 4
theme_override_constants/margin_bottom = 4
metadata/_edit_use_anchors_ = true
[node name="MainVBox" type="VBoxContainer" parent="MainMargin"]
custom_minimum_size = Vector2(200, 0)
layout_mode = 2
[node name="MenuBarMargin" type="MarginContainer" parent="MainMargin/MainVBox"]
layout_mode = 2
theme_override_constants/margin_bottom = 1
[node name="MenuBar" type="MenuBar" parent="MainMargin/MainVBox/MenuBarMargin" node_paths=PackedStringArray("file_menu", "edit_menu", "search_menu")]
layout_mode = 2
flat = true
script = ExtResource("5_bqqrn")
file_menu = NodePath("File")
edit_menu = NodePath("Edit")
search_menu = NodePath("Search")
[node name="File" type="PopupMenu" parent="MainMargin/MainVBox/MenuBarMargin/MenuBar"]
title = "File"
hide_on_checkable_item_selection = false
item_count = 7
item_0/text = "New"
item_0/id = 0
item_1/text = "Open..."
item_1/id = 1
item_2/id = -1
item_2/separator = true
item_3/text = "Save"
item_3/id = 2
item_4/text = "Save As..."
item_4/id = 3
item_5/id = -1
item_5/separator = true
item_6/text = "Close"
item_6/id = 4
[node name="Edit" type="PopupMenu" parent="MainMargin/MainVBox/MenuBarMargin/MenuBar"]
auto_translate_mode = 1
item_count = 8
item_0/text = "Undo"
item_0/id = 0
item_1/text = "Redo"
item_1/id = 1
item_2/id = -1
item_2/separator = true
item_3/text = "Cut"
item_3/id = 2
item_4/text = "Copy"
item_4/id = 3
item_5/text = "Paste"
item_5/id = 4
item_6/id = -1
item_6/separator = true
item_7/text = "Select All"
item_7/id = 5
[node name="Search" type="PopupMenu" parent="MainMargin/MainVBox/MenuBarMargin/MenuBar"]
auto_translate_mode = 1
item_count = 5
item_0/text = "Find..."
item_0/id = 0
item_1/text = "Find Next"
item_1/id = 1
item_2/text = "Find Previous"
item_2/id = 2
item_3/id = -1
item_3/separator = true
item_4/text = "Replace..."
item_4/id = 3
[node name="ResizableContainer" type="HSplitContainer" parent="MainMargin/MainVBox"]
visible = false
layout_mode = 2
size_flags_vertical = 3
split_offset = 150
[node name="FileList" type="VBoxContainer" parent="MainMargin/MainVBox/ResizableContainer" node_paths=PackedStringArray("filter_input", "file_list")]
custom_minimum_size = Vector2(150, 0)
layout_mode = 2
size_flags_stretch_ratio = 0.25
script = ExtResource("3_ufmqr")
filter_input = NodePath("FilterInput")
file_list = NodePath("ItemList")
[node name="FilterInput" type="LineEdit" parent="MainMargin/MainVBox/ResizableContainer/FileList"]
layout_mode = 2
placeholder_text = "Filter Files"
clear_button_enabled = true
right_icon = SubResource("ImageTexture_oh2kv")
[node name="ItemList" type="ItemList" parent="MainMargin/MainVBox/ResizableContainer/FileList"]
layout_mode = 2
size_flags_vertical = 3
allow_rmb_select = true
auto_height = true
[node name="EditorPanel" type="VBoxContainer" parent="MainMargin/MainVBox/ResizableContainer"]
layout_mode = 2
size_flags_horizontal = 3
[node name="YAMLCodeEdit" type="CodeEdit" parent="MainMargin/MainVBox/ResizableContainer/EditorPanel"]
layout_mode = 2
size_flags_vertical = 3
theme_override_font_sizes/font_size = 14
deselect_on_focus_loss_enabled = false
scroll_smooth = true
minimap_draw = true
syntax_highlighter = SubResource("EditorSyntaxHighlighter_68y40")
highlight_all_occurrences = true
highlight_current_line = true
draw_control_chars = true
draw_tabs = true
draw_spaces = true
line_folding = true
gutters_draw_breakpoints_gutter = true
gutters_draw_bookmarks = true
gutters_draw_executing_lines = true
gutters_draw_line_numbers = true
gutters_zero_pad_line_numbers = true
gutters_draw_fold_gutter = true
indent_size = 2
indent_use_spaces = true
indent_automatic = true
indent_automatic_prefixes = Array[String]([":"])
auto_brace_completion_enabled = true
auto_brace_completion_highlight_matching = true
script = ExtResource("2_lgcdy")
[node name="BottomToolbar" type="VBoxContainer" parent="MainMargin/MainVBox/ResizableContainer/EditorPanel"]
layout_mode = 2
[node name="StatusBarMargin" type="MarginContainer" parent="MainMargin/MainVBox/ResizableContainer/EditorPanel/BottomToolbar"]
layout_mode = 2
theme_override_constants/margin_left = 0
theme_override_constants/margin_top = 2
theme_override_constants/margin_right = 4
theme_override_constants/margin_bottom = 2
[node name="StatusBar" type="HBoxContainer" parent="MainMargin/MainVBox/ResizableContainer/EditorPanel/BottomToolbar/StatusBarMargin" node_paths=PackedStringArray("editor", "status_label", "zoom_button", "line_column_label")]
layout_mode = 2
script = ExtResource("6_rk03l")
editor = NodePath("../../../YAMLCodeEdit")
status_label = NodePath("StatusLabel")
zoom_button = NodePath("ZoomButton")
line_column_label = NodePath("LineColumnLabel")
[node name="StatusLabel" type="Label" parent="MainMargin/MainVBox/ResizableContainer/EditorPanel/BottomToolbar/StatusBarMargin/StatusBar"]
layout_mode = 2
size_flags_horizontal = 2
focus_mode = 2
theme_override_fonts/font = SubResource("SystemFont_k3025")
theme_override_styles/normal = SubResource("StyleBoxEmpty_42q8o")
vertical_alignment = 1
[node name="ZoomButton" type="Button" parent="MainMargin/MainVBox/ResizableContainer/EditorPanel/BottomToolbar/StatusBarMargin/StatusBar"]
layout_mode = 2
theme_override_fonts/font = SubResource("SystemFont_wntl2")
text = "100 %"
flat = true
[node name="VSeparator" type="VSeparator" parent="MainMargin/MainVBox/ResizableContainer/EditorPanel/BottomToolbar/StatusBarMargin/StatusBar"]
layout_mode = 2
theme_override_constants/separation = 6
[node name="LineColumnLabel" type="Label" parent="MainMargin/MainVBox/ResizableContainer/EditorPanel/BottomToolbar/StatusBarMargin/StatusBar"]
layout_mode = 2
size_flags_horizontal = 8
theme_override_fonts/font = SubResource("SystemFont_wntl2")
text = "1 : 1"
vertical_alignment = 1
[node name="FindReplaceBar" type="HBoxContainer" parent="MainMargin/MainVBox/ResizableContainer/EditorPanel/BottomToolbar" node_paths=PackedStringArray("find_input", "matches_label", "previous_button", "next_button", "match_case_checkbox", "whole_words_checkbox", "find_button_container", "find_options_container", "replace_input", "replace_button", "replace_all_button", "selection_only_checkbox", "replace_button_container", "replace_options_container", "hide_button", "editor", "vbox_container", "find_panel", "replace_panel")]
visible = false
layout_mode = 2
script = ExtResource("7_vo7uj")
find_input = NodePath("InputFieldContainer/FindInput")
matches_label = NodePath("ButtonsContainer/MatchesContainer/MatchesLabel")
previous_button = NodePath("ButtonsContainer/MatchesContainer/PreviousButton")
next_button = NodePath("ButtonsContainer/MatchesContainer/NextButton")
match_case_checkbox = NodePath("CheckBoxContainer/FindOptions/MatchCaseCheckbox")
whole_words_checkbox = NodePath("CheckBoxContainer/FindOptions/WholeWordsCheckbox")
find_button_container = NodePath("ButtonsContainer/MatchesContainer")
find_options_container = NodePath("CheckBoxContainer/FindOptions")
replace_input = NodePath("InputFieldContainer/ReplaceInput")
replace_button = NodePath("ButtonsContainer/ReplaceButtonsContainer/ReplaceButton")
replace_all_button = NodePath("ButtonsContainer/ReplaceButtonsContainer/ReplaceAllButton")
selection_only_checkbox = NodePath("CheckBoxContainer/ReplaceOptions/SelectionOnlyCheckBox")
replace_button_container = NodePath("ButtonsContainer/ReplaceButtonsContainer")
replace_options_container = NodePath("CheckBoxContainer/ReplaceOptions")
hide_button = NodePath("HideButton")
editor = NodePath("../../YAMLCodeEdit")
vbox_container = NodePath("CheckBoxContainer")
find_panel = NodePath("CheckBoxContainer/FindOptions")
replace_panel = NodePath("CheckBoxContainer/ReplaceOptions")
[node name="InputFieldContainer" type="VBoxContainer" parent="MainMargin/MainVBox/ResizableContainer/EditorPanel/BottomToolbar/FindReplaceBar"]
layout_mode = 2
size_flags_horizontal = 3
[node name="FindInput" type="LineEdit" parent="MainMargin/MainVBox/ResizableContainer/EditorPanel/BottomToolbar/FindReplaceBar/InputFieldContainer"]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
tooltip_text = "Find"
placeholder_text = "Find"
[node name="ReplaceInput" type="LineEdit" parent="MainMargin/MainVBox/ResizableContainer/EditorPanel/BottomToolbar/FindReplaceBar/InputFieldContainer"]
layout_mode = 2
size_flags_vertical = 3
placeholder_text = "Replace"
[node name="ButtonsContainer" type="VBoxContainer" parent="MainMargin/MainVBox/ResizableContainer/EditorPanel/BottomToolbar/FindReplaceBar"]
layout_mode = 2
size_flags_horizontal = 0
[node name="MatchesContainer" type="HBoxContainer" parent="MainMargin/MainVBox/ResizableContainer/EditorPanel/BottomToolbar/FindReplaceBar/ButtonsContainer"]
layout_mode = 2
size_flags_horizontal = 8
size_flags_vertical = 3
theme_override_constants/separation = 16
[node name="MatchesLabel" type="Label" parent="MainMargin/MainVBox/ResizableContainer/EditorPanel/BottomToolbar/FindReplaceBar/ButtonsContainer/MatchesContainer"]
layout_mode = 2
size_flags_horizontal = 0
size_flags_vertical = 1
theme_override_fonts/font = SubResource("SystemFont_k3025")
text = "10 matches"
vertical_alignment = 1
[node name="PreviousButton" type="Button" parent="MainMargin/MainVBox/ResizableContainer/EditorPanel/BottomToolbar/FindReplaceBar/ButtonsContainer/MatchesContainer"]
layout_mode = 2
tooltip_text = "Previous Match"
theme_override_styles/focus = SubResource("StyleBoxEmpty_i8tps")
theme_override_styles/disabled = SubResource("StyleBoxEmpty_r4bqd")
theme_override_styles/hover_pressed = SubResource("StyleBoxEmpty_3ksor")
theme_override_styles/hover = SubResource("StyleBoxEmpty_33vym")
theme_override_styles/pressed = SubResource("StyleBoxEmpty_jwbjn")
theme_override_styles/normal = SubResource("StyleBoxEmpty_xhoyn")
disabled = true
icon = SubResource("ImageTexture_oh2kv")
flat = true
[node name="NextButton" type="Button" parent="MainMargin/MainVBox/ResizableContainer/EditorPanel/BottomToolbar/FindReplaceBar/ButtonsContainer/MatchesContainer"]
layout_mode = 2
tooltip_text = "Next Match"
theme_override_styles/focus = SubResource("StyleBoxEmpty_vnqw7")
theme_override_styles/disabled = SubResource("StyleBoxEmpty_1i5or")
theme_override_styles/hover_pressed = SubResource("StyleBoxEmpty_vlfg7")
theme_override_styles/hover = SubResource("StyleBoxEmpty_ki2vh")
theme_override_styles/pressed = SubResource("StyleBoxEmpty_ke1f7")
theme_override_styles/normal = SubResource("StyleBoxEmpty_v3fgn")
disabled = true
icon = SubResource("ImageTexture_oh2kv")
flat = true
[node name="ReplaceButtonsContainer" type="HBoxContainer" parent="MainMargin/MainVBox/ResizableContainer/EditorPanel/BottomToolbar/FindReplaceBar/ButtonsContainer"]
layout_mode = 2
size_flags_horizontal = 8
size_flags_vertical = 3
[node name="ReplaceButton" type="Button" parent="MainMargin/MainVBox/ResizableContainer/EditorPanel/BottomToolbar/FindReplaceBar/ButtonsContainer/ReplaceButtonsContainer"]
layout_mode = 2
size_flags_horizontal = 8
size_flags_vertical = 4
disabled = true
text = "Replace"
[node name="ReplaceAllButton" type="Button" parent="MainMargin/MainVBox/ResizableContainer/EditorPanel/BottomToolbar/FindReplaceBar/ButtonsContainer/ReplaceButtonsContainer"]
layout_mode = 2
size_flags_horizontal = 8
size_flags_vertical = 4
disabled = true
text = "Replace All"
[node name="CheckBoxContainer" type="VBoxContainer" parent="MainMargin/MainVBox/ResizableContainer/EditorPanel/BottomToolbar/FindReplaceBar"]
layout_mode = 2
[node name="FindOptions" type="HBoxContainer" parent="MainMargin/MainVBox/ResizableContainer/EditorPanel/BottomToolbar/FindReplaceBar/CheckBoxContainer"]
custom_minimum_size = Vector2(0, 30)
layout_mode = 2
size_flags_horizontal = 0
size_flags_vertical = 3
theme_override_constants/separation = 4
[node name="MatchCaseCheckbox" type="CheckBox" parent="MainMargin/MainVBox/ResizableContainer/EditorPanel/BottomToolbar/FindReplaceBar/CheckBoxContainer/FindOptions"]
layout_mode = 2
text = "Match Case"
[node name="WholeWordsCheckbox" type="CheckBox" parent="MainMargin/MainVBox/ResizableContainer/EditorPanel/BottomToolbar/FindReplaceBar/CheckBoxContainer/FindOptions"]
layout_mode = 2
text = "Whole Words"
[node name="ReplaceOptions" type="HBoxContainer" parent="MainMargin/MainVBox/ResizableContainer/EditorPanel/BottomToolbar/FindReplaceBar/CheckBoxContainer"]
custom_minimum_size = Vector2(0, 30)
layout_mode = 2
size_flags_horizontal = 0
size_flags_vertical = 3
[node name="SelectionOnlyCheckBox" type="CheckBox" parent="MainMargin/MainVBox/ResizableContainer/EditorPanel/BottomToolbar/FindReplaceBar/CheckBoxContainer/ReplaceOptions"]
layout_mode = 2
text = "Selection Only
"
[node name="HideButton" type="Button" parent="MainMargin/MainVBox/ResizableContainer/EditorPanel/BottomToolbar/FindReplaceBar"]
layout_mode = 2
tooltip_text = "Hide"
icon = SubResource("ImageTexture_oh2kv")
flat = true
@@ -0,0 +1,3 @@
[gd_resource type="CanvasItemMaterial" format=3 uid="uid://g4tjqwskvd62"]
[resource]
@@ -0,0 +1,3 @@
[gd_resource type="Resource" format=3 uid="uid://pxagv74g8x67"]
[resource]
@@ -0,0 +1,21 @@
[gd_scene load_steps=3 format=3 uid="uid://cw7ukcablgkur"]
[ext_resource type="Material" uid="uid://g4tjqwskvd62" path="res://addons/yaml/examples/assets/material.tres" id="1_1ej5c"]
[ext_resource type="Texture2D" uid="uid://duefsqwwnmduy" path="res://icon.svg" id="1_t3dkf"]
[node name="Sprite" type="Sprite2D"]
material = ExtResource("1_1ej5c")
texture = ExtResource("1_t3dkf")
[node name="Label" type="Label" parent="."]
anchors_preset = 5
anchor_left = 0.5
anchor_right = 0.5
offset_left = -155.0
offset_top = -64.0
offset_right = 27.0
offset_bottom = -41.0
grow_horizontal = 2
text = "I was loaded with YAML"
horizontal_alignment = 1
vertical_alignment = 1
@@ -0,0 +1,53 @@
class_name MyCustomClass extends Node
@export var string_val: String
@export var int_val: int
@export var float_val: float
@export var color_val: Color
func _init(p_string := "", p_int := 0, p_float := 0.0, p_color = Color.WHITE) -> void:
string_val = p_string
int_val = p_int
float_val = p_float
color_val = p_color
func hello():
print(string_val)
static func deserialize(data: Variant):
if typeof(data) != TYPE_DICTIONARY:
return YAMLResult.error("Deserializing MyCustomClass expects Dictionary, received %s" % [type_string(typeof(data))])
var dict: Dictionary = data
if !dict.has("string_val"):
return YAMLResult.error("Missing string_val field")
if !dict.has("int_val"):
return YAMLResult.error("Missing int_val field")
if !dict.has("float_val"):
return YAMLResult.error("Missing float_val field")
if !dict.has("color_val"):
return YAMLResult.error("Missing color_val field")
var string_val: String = dict.get("string_val")
var int_val: int = dict.get("int_val")
var float_val: float = dict.get("float_val")
var color_val: Color = dict.get("color_val")
return MyCustomClass.new(
string_val,
int_val,
float_val,
color_val
)
func serialize() -> Dictionary:
return {
"string_val": string_val,
"int_val": int_val,
"float_val": float_val,
"color_val": color_val,
}
func _to_string() -> String:
return "MyCustomClass(%s)" % string_val
@@ -0,0 +1 @@
uid://c3bjbffcq7oc5
@@ -0,0 +1,53 @@
class_name MyCustomResource extends YAMLResource
@export var string_val: String
@export var int_val: int
@export var float_val: float
@export var color_val: Color
func _init(p_string := "", p_int := 0, p_float := 0.0, p_color = Color.WHITE) -> void:
string_val = p_string
int_val = p_int
float_val = p_float
color_val = p_color
func hello():
print(string_val)
static func deserialize(data: Variant):
if typeof(data) != TYPE_DICTIONARY:
return YAMLResult.error("Deserializing MyCustomResource expects Dictionary, received %s" % [type_string(typeof(data))])
var dict: Dictionary = data
if !dict.has("string_val"):
return YAMLResult.error("Missing string_val field")
if !dict.has("int_val"):
return YAMLResult.error("Missing int_val field")
if !dict.has("float_val"):
return YAMLResult.error("Missing float_val field")
if !dict.has("color_val"):
return YAMLResult.error("Missing color_val field")
var string_val: String = dict.get("string_val")
var int_val: int = dict.get("int_val")
var float_val: float = dict.get("float_val")
var color_val: Color = dict.get("color_val")
return MyCustomResource.new(
string_val,
int_val,
float_val,
color_val
)
func serialize() -> Dictionary:
return {
"string_val": string_val,
"int_val": int_val,
"float_val": float_val,
"color_val": color_val,
}
func _to_string() -> String:
return "MyCustomResource(%s)" % string_val
@@ -0,0 +1 @@
uid://dlwcjlb23nl02
@@ -0,0 +1,18 @@
class_name MyStringClass extends Object
@export var value: String
func _init(p_value := "") -> void:
value = p_value
static func deserialize(data: Variant):
if typeof(data) != TYPE_STRING:
return YAMLResult.error("Deserializing MyStringClass expects String, received %s" % [type_string(typeof(data))])
return MyStringClass.new(data)
func serialize() -> String:
return value
func _to_string() -> String:
return "MyStringClass(%s)" % value
@@ -0,0 +1 @@
uid://b6ljbsllu8xqn
@@ -0,0 +1,5 @@
!ruby:object/MyCustomClass
string_val: alternate tag
int_val: 42
float_val: 1.61803398875
color_val: !Color 000000
@@ -0,0 +1 @@
uid://deqds0xtv6aoi
@@ -0,0 +1,5 @@
title: Document 1
---
title: Document 2
---
title: Document 3
@@ -0,0 +1 @@
uid://cwyofk32x3sba
@@ -0,0 +1,16 @@
# The $id string is used as an identifier
$schema: "res://my_custom_class.yaml"
type: object
properties:
string_val:
type: string
minLength: 0
maxLength: 5
int_val:
type: int
max: 10
float_val:
type: float
min: 1
max: 999
@@ -0,0 +1 @@
uid://cpf0ajyys57i2
@@ -0,0 +1,17 @@
# The $id string is used as an identifier
$id: "res://my_custom_class_schema.yaml"
type: object
x-yaml-tag: foobar
properties:
string_val:
type: string
minLength: 1
maxLength: 5
int_val:
type: int
max: 10
float_val:
type: float
min: 0
max: 999
@@ -0,0 +1 @@
uid://fkxxl0sy2xrm
+7
View File
@@ -0,0 +1,7 @@
title: Godot YAML
author: FimbulWorks
release_year: 2025
features:
- Speaks fluent Variant and can be taught custom classes
- Strong document formatting capabilities
- Blazingly fast
@@ -0,0 +1 @@
uid://dn5kwv25bhn5g
@@ -0,0 +1,179 @@
# Strings
plain_string: Hello World
single_quoted_string: 'Hello, ''World'''
double_quoted_string: "Hello, World!"
literal_string: |
This is a long string
that will preserve
newlines
folded_string: >
This is a long string
that will be folded
into a single line
# Integer formats
integer: 42
hex: 0xff
octal: 0o700
binary: 0b1010
# Float formats
float: 3.14159
scientific_notation: 6.022e23
infinity: .inf
not_a_number: .nan
# Boolean types
boolean_true: true
boolean_false: false
# Null
null_value: null
null_value_2: ~
# Dates and times are treated as strings
date: 2023-04-01
canonical_datetime: 2023-04-01T12:00:00Z
iso8601_datetime: 2023-04-01t12:00:00.000-05:00
# Arrays
fruits:
- apple
- banana
- cherry
nested_array:
- - nested
- items
- - more
- nested
- items
## Dictionaries
person:
name: John Doe
age: 30
occupation: Developer
## Flow style array
flow_style_array: [1, 2, 3, 4, 5]
## Flow style dictionary
flow_style_dict: {key1: value1, key2: value2, key3: 124.5}
## Nested flow array
nested_flow_style_array: ["hello", 10, {foo: bar}, [4.20, 6.9]]
## Nested flow dictionary
nested_flow_style_dictionary: { key1: {foo: 'bar'}, key2: [4.20, 6.9] }
# Anchors and aliases get resolved during parsing
anchor_example: &anchor_name
key1: value1
key2: value2
alias_example: *anchor_name
# Merge keys are also resolved during parsing
base: &base
name: John Doe
age: 30
merge_example:
<<: *base
occupation: Developer
# Binary data - !!binary is converted to !PackedByteArray
binary_data: !!binary |
R0lGODlhDAAMAIQAAP//9/X17unp5WZmZgAAAOfn515eXvPz7Y6OjuDg4J+fn5
OTk6enp56enmleECcgggoBADs=
# Custom tags are preserved, and can describe Godot Variants,
# and classes registered using YAML.register_class()
game_object: !MyHeroClass
name: "Hero"
health: 100
inventory:
- sword
- shield
- potion_healing
# Variants
AABB: !AABB
position: {x: 1,y: 2,z: 4}
size: {x: 8,y: 16,z: 32}
Basis: !Basis
x: {x: 1,y: 2,z: 4}
y: {x: 8,y: 16,z: 32}
z: {x: 64,y: 128,z: 256}
Color: !Color ff804080
NodePath: !NodePath "root/level/player"
PackedByteArray: !PackedByteArray |
AQIECA==
PackedColorArray: !PackedColorArray
- ff0000
- 00ff00
- 0000ff
- red
- green
- blue
PackedFloat32Array: !PackedFloat32Array
- 3.1415927
- 6.2831855
- 12.566371
PackedFloat64Array: !PackedFloat64Array
- 3.141592653589793
- 6.283185307179586
- 12.566370614359172
PackedInt32Array: !PackedInt32Array
- 1
- 2
- 4
- 8
PackedInt64Array: !PackedInt64Array
- 1
- 2
- 4
- 8
PackedStringArray: !PackedStringArray
- "one"
- "one\ntwo"
- "one\ntwo\nthree"
PackedVector2Array: !PackedVector2Array
- {x: 1,y: 2}
- {x: 4,y: 8}
PackedVector3Array: !PackedVector3Array
- {x: 1,y: 2,z: 4}
- {x: 8,y: 16,z: 32}
Plane: !Plane
normal: {x: 1,y: 2,z: 4}
d: 3.1415927
Projection: !Projection
x: {x: 1,y: 2,z: 4,w: 8}
y: {x: 16,y: 32,z: 64,w: 128}
z: {x: 256,y: 512,z: 1024,w: 2048}
w: {x: 4096,y: 8192,z: 16384,w: 32768}
Quaternion: !Quaternion {x: 3.1415927,y: 6.2831855,z: 12.566371,w: 25.132742}
Rect2: !Rect2
position: {x: 1,y: 2}
size: {x: 4,y: 8}
Rect2i: !Rect2i
position: {x: 2,y: 4}
size: {x: 8,y: 16}
StringName: !StringName test_string_name
Transform2D: !Transform2D
x: {x: -1,y: -8.742278e-08}
y: {x: 8.742278e-08,y: -1}
origin: {x: 6.2831855,y: 12.566371}
Transform3D: !Transform3D
basis:
x: {x: 1,y: 0,z: 0}
y: {x: 0,y: 1,z: 0}
z: {x: 0,y: 0,z: 1}
origin: {x: 1,y: 2,z: 4}
Vector2: !Vector2 {x: 1,y: 2}
Vector2i: !Vector2i {x: 2,y: 4}
Vector3: !Vector3 {x: 1,y: 2,z: 4}
Vector3i: !Vector3i {x: 2,y: 4,z: 8}
Vector4: !Vector4 {x: 1,y: 2,z: 4,w: 8}
Vector4i: !Vector4i {x: 2,y: 4,z: 8,w: 16}
@@ -0,0 +1 @@
uid://b24xb1mhxmg8i
@@ -0,0 +1,50 @@
$schema: "res://addons/yaml/examples/data/fimbul.generator.schema.yaml"
params:
x: float
y: float
noiseScale: float
noise2D: Callable
functions:
- name: continentShape
params: ["x", "y"]
returns: float
code: abs(cos(x * PI * 2 + PI * 0.5) * sin(y * PI))
- name: heightNoise
params: ["x", "y", "noiseScale", "noise2D"]
returns: float
code: noise2D(x * noiseScale, y * noiseScale) * 0.5 + 0.5
- name: height
dependencies: ["continentShape", "heightNoise"]
returns: float
code: continentShape * heightNoise
- name: temperature
params: ["y"]
dependencies: ["height"]
returns: float
code: y - (height - 0.4) * 2 if height > 0.4 else y
- name: precipitation
params: ["y"]
dependencies: ["temperature"]
returns: float
code: 1 - temperature
- name: biome
returns: String
dependencies: ["height", "temperature", "precipitation"]
code: |
if height < 0.2023:
return 'ocean'
if temperature >= 0.666:
return 'desert'
if temperature > 0.42 && precipitation > 0.42:
return 'rainforest'
if temperature > 0.3 && precipitation > 0.3:
return 'forest'
if temperature <= 0.21:
return 'tundra'
return 'meadows'
@@ -0,0 +1 @@
uid://drfjm1uj06e4x
+66
View File
@@ -0,0 +1,66 @@
class_name ExampleBase extends Node2D
# Whether to show detailed logs
var LOG_VERBOSE := false
# Extra emoji to make logs visually distinct
var icon := ""
# Hook that runs all examples in the class
func _ready() -> void:
if !visible:
return
print_rich("\n[b][font_size=16]%s%s[/font_size][/b]" % [
"%s " % icon if icon.length() > 0 else "",
name
])
run_examples()
# Override this to run examples in your class
func run_examples() -> void:
# Child classes should override this method
pass
# Logging Helpers
func log_header(text: String) -> void:
print_rich("\n[b][font_size=16]%s[/font_size][/b]" % text)
func log_subheader(text: String) -> void:
print_rich("\n[b][font_size=14]%s[/font_size][/b]" % text)
func log_success(text: Variant) -> void:
print_rich("[color=green]✅ %s[/color]" % str(text))
func log_error(text: Variant) -> void:
print_rich("[color=red]❌ %s[/color]" % str(text))
func log_warning(text: Variant) -> void:
print_rich("[color=yellow]⚠️ %s[/color]" % str(text))
func log_info(text: Variant) -> void:
print_rich("%s" % str(text))
func log_code_block(code: String) -> void:
print_rich("\n[b]Code:[/b]")
print_rich("[color=#aaaaff]%s" % code)
func log_result(text: Variant) -> void:
print_rich("\n[b]Result:[/b]\n%s" % str(text))
# Run a single example with timing
func run_example(title: String, method: Callable) -> void:
print_rich("\n[b][font_size=14]%s[/font_size][/b]" % title)
var start_time := Time.get_ticks_usec()
method.call()
var elapsed := Time.get_ticks_usec() - start_time
var t: float = elapsed
var tl = "µsec"
if t > 1000:
t /= 1000.0
tl = "ms"
print_rich("\n[color=#888888]Completed in %.2f %s[/color]" % [t, tl])
+1
View File
@@ -0,0 +1 @@
uid://bfxd6dh800cuf
+140
View File
@@ -0,0 +1,140 @@
extends ExampleBase
const YAML_FILE = "res://addons/yaml/examples/data/supported_syntax.yaml"
const OUTPUT_FILE = "user://supported_syntax_copy.yaml"
var yaml_text := """
string: string_value
number: 1234
list:
- apples
- oranges
"""
var data
func _init() -> void:
icon = "📝"
func run_examples() -> void:
run_example("Validate YAML String Syntax", validate_yaml_string_syntax)
run_example("Parse YAML Text", parse_yaml_text)
run_example("Stringify Data", stringify_data)
run_example("Validate File Syntax", validate_file_syntax)
run_example("Load File", load_file)
run_example("Save File", save_file)
run_example("Load Saved File", load_saved_file)
func validate_yaml_string_syntax() -> void:
log_code_block(yaml_text)
log_info("Validating YAML string...")
var result := YAML.validate_syntax(yaml_text)
if result.has_error():
log_error("Validation failed: " + result.get_error())
return
log_success("YAML is valid!")
func parse_yaml_text() -> void:
log_info("Parsing YAML text...")
var result := YAML.parse(yaml_text)
if result.has_error():
log_error("Parse failed: " + result.get_error())
return
data = result.get_data()
log_success("YAML parsed successfully")
if LOG_VERBOSE:
log_result(str(data))
log_info("Accessing data values:")
log_info("• string value: " + data.string)
log_info("• number value: " + str(data.number))
log_info("• list items: " + str(data.list))
func stringify_data() -> void:
# First ensure we have data to stringify
if data == null:
data = YAML.parse(yaml_text).get_data()
log_info("Converting data structure to YAML string...")
var result := YAML.stringify(data)
if result.has_error():
log_error("Stringify failed: " + result.get_error())
return
log_success("Data converted to YAML successfully")
if LOG_VERBOSE:
log_result(result.get_data())
# Verify the round trip
var yaml_output = result.get_data().strip_edges()
var original = yaml_text.strip_edges()
if yaml_output == original:
log_success("Round-trip verification: Output matches original")
else:
log_warning("Round-trip produced different output (semantically equivalent)")
log_info("Original:\n" + original)
log_info("Output:\n" + yaml_output)
func validate_file_syntax() -> void:
log_info("Validating YAML file: " + YAML_FILE)
var result := YAML.validate_file_syntax(YAML_FILE)
if result.has_error():
log_error("File validation failed: " + result.get_error())
return
log_success("YAML file is valid")
func load_file() -> void:
log_info("Loading YAML file: " + YAML_FILE)
var result := YAML.load_file(YAML_FILE)
if result.has_error():
log_error("File loading failed: " + result.get_error())
return
data = result.get_data()
log_success("YAML file loaded successfully")
if LOG_VERBOSE:
log_info("File contains " + str(data.size()) + " keys")
log_result(str(data).substr(0, 500) + "...\n(output truncated)")
func save_file() -> void:
# Make sure we have data
if data == null:
data = {"example": "data", "created": "now", "values": [1, 2, 3]}
log_info("Saving data to YAML file: " + OUTPUT_FILE)
var result := YAML.save_file(data, OUTPUT_FILE)
if result.has_error():
log_error("File saving failed: " + result.get_error())
return
log_success("Data saved to YAML file successfully")
if LOG_VERBOSE:
log_result(result.get_data())
func load_saved_file() -> void:
log_info("Loading previously saved file: " + OUTPUT_FILE)
var result := YAML.load_file(OUTPUT_FILE)
if result.has_error():
log_error("File loading failed: " + result.get_error())
return
var loaded_data = result.get_data()
log_success("Saved file loaded successfully")
if LOG_VERBOSE:
log_result(str(loaded_data))
@@ -0,0 +1 @@
uid://cemrjp34s7ujk
@@ -0,0 +1,239 @@
extends ExampleBase
func _init() -> void:
icon = "🧩"
LOG_VERBOSE = true
func _enter_tree() -> void:
# Register our custom classes when the node enters the tree
YAML.register_class(MyCustomClass, "serialize", "deserialize", "ruby/object:MyCustomClass")
YAML.register_class(MyCustomResource)
YAML.register_class(MyStringClass)
func _exit_tree() -> void:
# Clean up registrations when the node exits the tree
YAML.unregister_class(MyCustomClass)
YAML.unregister_class(MyCustomResource)
YAML.unregister_class(MyStringClass)
func run_examples() -> void:
run_example("Custom Node Class", custom_node_class)
run_example("Custom Class Errors", custom_class_errors)
run_example("String-Based Custom Class", custom_string_class)
run_example("Custom Resource Class", custom_resource)
run_example("Custom Resource Errors", custom_resource_errors)
run_example("Class Registration Management", class_registration_management)
func custom_node_class() -> void:
log_info("Creating instance of MyCustomClass...")
var object = MyCustomClass.new("hello world", 123, PI)
log_info("Stringifying custom class instance to YAML...")
var str_result := YAML.stringify(object)
if str_result.has_error():
log_error("Stringify failed: " + str_result.get_error())
return
var yaml_text: String = str_result.get_data()
log_success("Custom class stringified successfully")
if LOG_VERBOSE:
log_info("MyCustomClass as YAML:\n" + yaml_text)
log_info("Parsing YAML back into MyCustomClass...")
var parse_result := YAML.parse(yaml_text)
if parse_result.has_error():
log_error("Parse failed: " + parse_result.get_error())
return
var obj: MyCustomClass = parse_result.get_data()
if obj is MyCustomClass:
log_success("YAML parsed back into MyCustomClass")
log_info("string_val: " + obj.string_val)
log_info("int_val: " + str(obj.int_val))
log_info("float_val: " + str(obj.float_val))
log_info("color_val: " + str(obj.color_val))
else:
log_error("Failed to parse back into MyCustomClass")
func custom_class_errors() -> void:
log_info("Testing error handling with invalid MyCustomClass YAML...")
# Missing required field
var yaml_text = """
!MyCustomClass
string_val: foo
"""
log_code_block(yaml_text)
log_info("Attempting to parse with missing required fields...")
var result := YAML.parse(yaml_text)
if result.has_error():
log_success("Correctly detected missing field error")
log_info("Error message: " + result.get_error())
else:
log_error("Failed to detect missing required field")
# Invalid data structure
var invalid_yaml_text = """
!MyCustomClass
[1, 2, 3]
"""
log_code_block(invalid_yaml_text)
log_info("Attempting to parse with wrong data structure...")
var bad_result := YAML.parse(invalid_yaml_text)
if bad_result.has_error():
log_success("Correctly detected wrong data structure error")
log_info("Error message: " + bad_result.get_error())
else:
log_error("Failed to detect wrong data structure")
func custom_string_class() -> void:
log_info("Creating instance of string-based MyStringClass...")
var object = MyStringClass.new("hello world")
log_info("Stringifying string-based class to YAML...")
var str_result := YAML.stringify(object)
if str_result.has_error():
log_error("Stringify failed: " + str_result.get_error())
return
var text: String = str_result.get_data()
log_success("String-based class stringified successfully")
if LOG_VERBOSE:
log_result("MyStringClass as YAML:\n" + text)
log_info("Parsing YAML back into MyStringClass...")
var parse_result := YAML.parse(text)
if parse_result.has_error():
log_error("Parse failed: " + parse_result.get_error())
return
var obj = parse_result.get_data()
if obj is MyStringClass:
log_success("YAML parsed back into MyStringClass")
log_info("Value: " + obj.value)
else:
log_error("Failed to parse back into MyStringClass")
func custom_resource() -> void:
log_info("Creating instance of MyCustomResource...")
var resource = MyCustomResource.new("I am resource", 42, 69.69)
log_info("Stringifying resource class to YAML...")
var str_result := YAML.stringify(resource)
if str_result.has_error():
log_error("Stringify failed: " + str_result.get_error())
return
var yaml_text: String = str_result.get_data()
log_success("Resource class stringified successfully")
if LOG_VERBOSE:
log_result("MyCustomResource as YAML:\n" + yaml_text)
log_info("Parsing YAML back into MyCustomResource...")
var parse_result := YAML.parse(yaml_text)
if parse_result.has_error():
log_error("Parse failed: " + parse_result.get_error())
return
var obj = parse_result.get_data()
if obj is MyCustomResource:
log_success("YAML parsed back into MyCustomResource")
log_info("string_val: " + obj.string_val)
log_info("int_val: " + str(obj.int_val))
log_info("float_val: " + str(obj.float_val))
log_info("color_val: " + str(obj.color_val))
else:
log_error("Failed to parse back into MyCustomResource")
func custom_resource_errors() -> void:
log_info("Testing error handling with invalid MyCustomResource YAML...")
# Missing required field
var yaml_text = """
!MyCustomResource
color_val: black
"""
log_code_block(yaml_text)
log_info("Attempting to parse with missing required fields...")
var result := YAML.parse(yaml_text)
if result.has_error():
log_success("Correctly detected missing field error")
log_info("Error message: " + result.get_error())
else:
log_error("Failed to detect missing required field")
# Invalid data structure
var invalid_yaml_text = """
!MyCustomResource
[1, 2, 3]
"""
log_code_block(invalid_yaml_text)
log_info("Attempting to parse with wrong data structure...")
var bad_result := YAML.parse(invalid_yaml_text)
if bad_result.has_error():
log_success("Correctly detected wrong data structure error")
log_info("Error message: " + bad_result.get_error())
else:
log_error("Failed to detect wrong data structure")
func class_registration_management() -> void:
log_subheader("Class Registration Management")
log_info("Checking if classes are registered...")
if YAML.has_registered_class("MyCustomClass2"):
log_success("MyCustomClass is registered")
else:
log_error("MyCustomClass is not registered properly")
if YAML.has_registered_class("MyCustomResource"):
log_success("MyCustomResource is registered")
else:
log_error("MyCustomResource is not registered properly")
if YAML.has_registered_class("MyStringClass"):
log_success("MyStringClass is registered")
else:
log_error("MyStringClass is not registered properly")
log_info("\nExample of registering with custom methods:")
var code = """
# Register with custom method names
YAML.register_class(MyCustomClass, "to_yaml", "from_yaml")
# Methods in the class would then be:
func to_yaml():
# Custom serialization code
return {...}
static func from_yaml(data):
# Custom deserialization code
return MyCustomClass.new(...)
"""
log_code_block(code)
log_info("\nBest practices for class registration:")
log_info("1. Register classes at startup in an autoload/singleton")
log_info("2. Use consistent naming for serialize/deserialize methods")
log_info("3. Implement thorough validation in deserialize methods")
log_info("4. For scripts that might be unloaded, register in _enter_tree and unregister in _exit_tree")
@@ -0,0 +1 @@
uid://ch12k840j6ak6
@@ -0,0 +1,161 @@
extends ExampleBase
func _init() -> void:
icon = ""
func run_examples() -> void:
run_example("Invalid Indentation", invalid_indentation)
run_example("Unmatched Quotes", unmatched_quotes)
run_example("Circular Reference", circular_reference)
run_example("Validation Example", validation_example)
run_example("Error Details", error_details)
run_example("Error Handling Patterns", error_handling_patterns)
func invalid_indentation() -> void:
var invalid_yaml := """
key: value
indentation: wrong
"""
log_code_block(invalid_yaml)
log_info("Parsing YAML with invalid indentation...")
var result := YAML.parse(invalid_yaml)
if result.has_error():
log_success("Correctly detected error: " + result.get_error())
log_info("Line: " + str(result.get_error_line()))
log_info("Column: " + str(result.get_error_column()))
return
log_error("Failed to detect invalid indentation")
func unmatched_quotes() -> void:
var unmatched_quotes := """
message: "This quote is not closed
next_line: value
"""
log_code_block(unmatched_quotes)
log_info("Parsing YAML with unmatched quotes...")
var result = YAML.parse(unmatched_quotes)
if result.has_error():
log_success("Correctly detected error: " + result.get_error())
return
log_error("Failed to detect unmatched quotes")
func circular_reference() -> void:
log_info("Creating circular reference in data structure...")
# Create a circular reference
var dict1 = {}
var dict2 = {"ref": dict1}
dict1["circular"] = dict2
log_info("Attempting to stringify circular reference...")
var result := YAML.stringify(dict1)
if result.has_error():
log_success("Correctly detected error: " + result.get_error())
return
log_error("Failed to detect circular reference")
func validation_example() -> void:
var invalid_yaml := """
key: value
- invalid
list
format
"""
log_code_block(invalid_yaml)
log_info("Validating incorrect YAML...")
var result = YAML.validate_syntax(invalid_yaml)
if result.has_error():
log_success("Validation correctly detected error: " + result.get_error())
return
log_error("Validation failed to detect invalid YAML")
func error_details() -> void:
var yaml_with_error := """
valid_line: value
- invalid line: value
another_line: value
"""
log_code_block(yaml_with_error)
log_info("Examining error details...")
var result := YAML.parse(yaml_with_error)
if result.has_error():
log_success("Error detected: " + result.get_error())
# Show detailed error information
log_info("Error message: " + result.get_error_message())
log_info("Error line: " + str(result.get_error_line()))
log_info("Error column: " + str(result.get_error_column()))
# Highlight the error line
var yaml_lines = yaml_with_error.split("\n")
if result.get_error_line() > 0 and result.get_error_line() <= yaml_lines.size():
var error_line = yaml_lines[result.get_error_line() - 1]
log_info("Line content: " + error_line)
if result.get_error_column() > 0:
var pointer = " ".repeat(result.get_error_column() - 1) + "^"
log_info(pointer + " Error position")
return
log_error("Failed to detect error")
func error_handling_patterns() -> void:
log_subheader("Error Handling Patterns")
# Pattern 1: Try-parse pattern
log_info("Pattern 1: Using try_parse for simplified error handling")
var yaml_text = """
name: Example
valid: true
"""
var data = YAML.try_parse(yaml_text)
if data:
log_success("try_parse succeeded")
log_info("Data: " + str(data))
else:
log_error("try_parse failed")
# Pattern 2: get_error vs get_error_message
log_info("\nPattern 2: Detailed vs Simple Error Messages")
var invalid_yaml = "key: [invalid"
var result = YAML.parse(invalid_yaml)
if result.has_error():
log_info("Detailed error: " + result.get_error())
log_info("Simple message: " + result.get_error_message())
# Pattern 3: Creating custom errors
log_info("\nPattern 3: Creating custom validation errors")
var config_yaml = """
name: MyApp
# version is missing
"""
var parsed = YAML.parse(config_yaml)
if !parsed.has_error():
var validation = validate_config(parsed.get_data())
if validation.has_error():
log_success("Custom validation error: " + validation.get_error_message())
else:
log_info("Config is valid")
else:
log_error("Parse error: " + parsed.get_error())
func validate_config(data):
if !data.has("name"):
return YAMLResult.error("Configuration missing 'name' field")
if !data.has("version"):
return YAMLResult.error("Configuration missing 'version' field")
return data
@@ -0,0 +1 @@
uid://d015opxjbyce4
@@ -0,0 +1,258 @@
extends ExampleBase
const MULTI_DOC_FILE = "res://addons/yaml/examples/data/multi_document.yaml"
const OUTPUT_FILE = "user://multi_document_copy.yaml"
var multi_doc_yaml := """
# Configuration Document
name: MyApplication
version: 1.2.3
environment: production
---
# Database Settings
database:
host: localhost
port: 5432
name: myapp_db
credentials:
username: admin
password: secret123
---
# Feature Flags
features:
enable_new_ui: true
enable_analytics: false
enable_caching: true
max_connections: 100
---
# Logging Configuration
logging:
level: INFO
handlers:
- console
- file
file_path: /var/log/myapp.log
""".replace(" ", " ") # Handle Godot's tab indentation
var parsed_documents
func _init() -> void:
icon = "📑"
func run_examples() -> void:
run_example("Validate Multi-Document YAML", validate_multi_document)
run_example("Parse Multi-Document YAML", parse_multi_document)
run_example("Access Individual Documents", access_individual_documents)
run_example("Work with Document Count", work_with_document_count)
run_example("Process All Documents", process_all_documents)
run_example("Create Multi-Document YAML", create_multi_document)
run_example("Save Multi-Document File", save_multi_document_file)
run_example("Load Multi-Document File", load_multi_document_file)
func validate_multi_document() -> void:
log_info("Validating multi-document YAML string...")
var result := YAML.validate_syntax(multi_doc_yaml)
if result.has_error():
log_error("Validation failed: " + result.get_error())
return
log_success("Multi-document YAML is valid!")
func parse_multi_document() -> void:
log_info("Parsing multi-document YAML...")
var result := YAML.parse(multi_doc_yaml)
if result.has_error():
log_error("Parse failed: " + result.get_error())
return
parsed_documents = result
log_success("Multi-document YAML parsed successfully")
if LOG_VERBOSE:
log_info("Has multiple documents: %s" % result.has_multiple_documents())
log_info("Found " + str(result.get_document_count()) + " documents")
func access_individual_documents() -> void:
# Ensure we have parsed documents
if parsed_documents == null:
parsed_documents = YAML.parse(multi_doc_yaml)
log_info("Accessing individual documents...")
# Access first document (configuration)
var config_doc = parsed_documents.get_document(0)
if config_doc != null:
log_success("Document 0 - Configuration:")
log_info("• App name: " + str(config_doc.name))
log_info("• Version: " + str(config_doc.version))
log_info("• Environment: " + str(config_doc.environment))
# Access second document (database settings)
var db_doc = parsed_documents.get_document(1)
if db_doc != null:
log_success("Document 1 - Database Settings:")
log_info("• Host: " + str(db_doc.database.host))
log_info("• Port: " + str(db_doc.database.port))
log_info("• Database: " + str(db_doc.database.name))
# Access third document (feature flags)
var features_doc = parsed_documents.get_document(2)
if features_doc != null:
log_success("Document 2 - Feature Flags:")
log_info("• New UI enabled: " + str(features_doc.features.enable_new_ui))
log_info("• Analytics enabled: " + str(features_doc.features.enable_analytics))
log_info("• Max connections: " + str(features_doc.features.max_connections))
# Try to access non-existent document
var non_existent = parsed_documents.get_document(10)
if non_existent == null:
log_info("Document 10 (non-existent): null")
func work_with_document_count() -> void:
if parsed_documents == null:
parsed_documents = YAML.parse(multi_doc_yaml)
var count = parsed_documents.get_document_count()
log_info("Total document count: " + str(count))
log_info("Iterating through all documents by index:")
for i in range(count):
var doc = parsed_documents.get_document(i)
var doc_type = "Unknown"
# Identify document type by its content
if doc.has("name") and doc.has("version"):
doc_type = "Configuration"
elif doc.has("database"):
doc_type = "Database Settings"
elif doc.has("features"):
doc_type = "Feature Flags"
elif doc.has("logging"):
doc_type = "Logging Configuration"
log_info("• Document " + str(i) + ": " + doc_type)
func process_all_documents() -> void:
if parsed_documents == null:
parsed_documents = YAML.parse(multi_doc_yaml)
log_info("Processing all documents at once...")
var all_docs = parsed_documents.get_documents()
log_success("Retrieved " + str(all_docs.size()) + " documents as array")
if LOG_VERBOSE:
for i in range(all_docs.size()):
log_info("Document " + str(i) + " keys: " + str(all_docs[i].keys()))
func create_multi_document() -> void:
log_info("Creating multi-document YAML from separate data structures...")
# Create individual document data
var user_doc = {
"user": {
"id": 12345,
"name": "John Doe",
"email": "john@example.com"
}
}
var preferences_doc = {
"preferences": {
"theme": "dark",
"language": "en",
"notifications": true
}
}
var session_doc = {
"session": {
"token": "abc123xyz",
"expires": "2024-12-31T23:59:59Z",
"permissions": ["read", "write"]
}
}
# Convert each to YAML and combine
var documents_yaml = []
for doc_data in [user_doc, preferences_doc, session_doc]:
var result = YAML.stringify(doc_data)
if result.has_error():
log_error("Failed to stringify document: " + result.get_error())
return
documents_yaml.append(result.get_data())
# Combine with document separator
var combined_yaml = "\n---\n".join(documents_yaml)
log_success("Created multi-document YAML successfully")
if LOG_VERBOSE:
log_result(combined_yaml)
# Verify by parsing it back
var verify_result = YAML.parse(combined_yaml)
if !verify_result.has_error():
log_success("Verification: " + str(verify_result.get_document_count()) + " documents parsed")
func save_multi_document_file() -> void:
log_info("Saving multi-document YAML to file: " + OUTPUT_FILE)
# Create sample multi-document data
var documents = [
{"metadata": {"created": "2024-01-01", "version": 1}},
{"data": {"items": [1, 2, 3], "total": 6}},
{"summary": {"status": "complete", "processed": true}}
]
var yaml_parts = []
for doc in documents:
var result = YAML.stringify(doc)
if result.has_error():
log_error("Failed to stringify document: " + result.get_error())
return
yaml_parts.append(result.get_data())
var multi_doc_content = "\n---\n".join(yaml_parts)
# Save to file (note: this saves as plain text, not using YAML.save_file)
var file = FileAccess.open(OUTPUT_FILE, FileAccess.WRITE)
if file == null:
log_error("Failed to open file for writing")
return
file.store_string(multi_doc_content)
file.close()
log_success("Multi-document YAML saved successfully")
if LOG_VERBOSE:
log_result("File content:\n" + multi_doc_content)
func load_multi_document_file() -> void:
log_info("Loading multi-document YAML file: " + OUTPUT_FILE)
# Check if file exists
if !FileAccess.file_exists(OUTPUT_FILE):
log_warning("File doesn't exist. Run 'Save Multi-Document File' first.")
return
var result := YAML.load_file(OUTPUT_FILE)
if result.has_error():
log_error("File loading failed: " + result.get_error())
return
log_success("Multi-document YAML file loaded successfully")
if LOG_VERBOSE:
var doc_count = result.get_document_count()
log_info("Loaded " + str(doc_count) + " documents from file")
for i in range(doc_count):
var doc = result.get_document(i)
log_info("Document " + str(i) + ": " + str(doc.keys()))
log_result(" " + str(doc))
@@ -0,0 +1 @@
uid://bpwc2ppvsfwo5
@@ -0,0 +1,174 @@
extends ExampleBase
# This example requires a local texture reference
# For real use, export a texture in your scene
@export var local_texture: Texture2D
func _init() -> void:
icon = "🔗"
func run_examples() -> void:
run_example("Parsing Resources", parsing_resources)
run_example("Stringifying Resources", stringifying_resources)
run_example("Resource Security", resource_security)
run_example("Resource Path Management", resource_path_management)
func parsing_resources() -> void:
var yaml_text := """
scene: !Resource 'res://addons/yaml/examples/assets/simple_scene.tscn'
texture: !Resource 'res://icon.svg'
yaml: !Resource 'res://addons/yaml/examples/data/simple.yaml'
"""
log_code_block(yaml_text)
log_info("Parsing YAML with resource references...")
var parse_result := YAML.parse(yaml_text)
if parse_result.has_error():
log_error("Parse failed: " + parse_result.get_error())
return
var data: Dictionary = parse_result.get_data()
log_success("YAML with resources parsed successfully")
if data.scene is PackedScene:
log_success("PackedScene loaded successfully")
var scene = data.scene.instantiate()
var screen_width: int = ProjectSettings.get_setting("display/window/size/viewport_width")
var screen_height: int = ProjectSettings.get_setting("display/window/size/viewport_height")
scene.position = Vector2(screen_width / 2, screen_height / 2)
add_child(scene)
else:
log_warning("PackedScene not loaded (might be missing file or security restrictions)")
if data.texture is Texture2D:
log_success("Texture loaded successfully")
else:
log_warning("Texture not loaded (might be missing file or security restrictions)")
if data.yaml is Dictionary:
log_success("Nested YAML loaded successfully")
else:
log_warning("Nested YAML not loaded (might be missing file or security restrictions)")
if LOG_VERBOSE:
log_info("Resource types:")
log_info("• scene type: " + str(typeof(data.scene)))
log_info("• texture type: " + str(typeof(data.texture)))
log_info("YAML resource:")
log_info(str(data.yaml))
func stringifying_resources() -> void:
log_info("Stringifying a resource...")
var resource = load("res://icon.svg") # Project icon should always exist
if !resource:
log_error("Could not load resource")
return
var str_result := YAML.stringify(resource)
if str_result.has_error():
log_error("Stringify failed: " + str_result.get_error())
return
log_success("Resource stringified successfully")
if LOG_VERBOSE:
log_result("Stringified Resource:\n" + str_result.get_data())
# Test stringifying local resource (should fail)
if local_texture:
log_info("Attempting to stringify local (non-file) resource...")
var invalid_result := YAML.stringify(local_texture)
if invalid_result.has_error():
log_success("Correctly failed to serialize local resource")
log_info("Error: " + invalid_result.get_error())
else:
log_error("Incorrectly serialized local resource")
else:
log_warning("No local texture assigned, skipping local resource test")
func resource_security() -> void:
log_info("Testing resource security...")
var yaml_text := """
script: !Resource 'res://addons/yaml/examples/scripts/dangerous_script.gd'
"""
log_code_block(yaml_text)
log_info("Parsing with default security...")
var parse_result := YAML.parse(yaml_text)
if parse_result.has_error():
log_success("Default security correctly blocked script resource")
log_info("Error: " + parse_result.get_error())
else:
log_error("Default security failed to block script resource")
log_info("Creating custom security with explicit type blocks...")
var security := YAML.create_security()
security.block_type("Script")
security.block_type("GDExtension")
security.allow_path("res://**", ["Texture2D", "PackedScene"])
log_info("Same file with custom security...")
parse_result = YAML.parse(yaml_text, security)
if parse_result.has_error():
log_success("Custom security correctly blocked script resource")
log_info("Error: " + parse_result.get_error())
else:
log_error("Custom security failed to block script resource")
func resource_path_management() -> void:
log_subheader("Resource Path Management")
log_info("Using relative paths vs absolute paths:")
var yaml_with_absolute_path := """
texture: !Resource 'res://icon.svg'
"""
log_code_block(yaml_with_absolute_path)
log_info("Absolute paths are fixed to specific locations")
var yaml_with_relative_path := """
# When file is in res://addons/yaml/examples/
texture: !Resource '../../../icon.svg'
"""
log_code_block(yaml_with_relative_path)
log_warning("Note: Godot doesn't natively support relative paths in Resource paths")
log_info("You need to implement custom path resolution for relative paths")
log_info("\nSuggested best practices:")
log_info("1. Use absolute paths (res://, user://) for resources")
log_info("2. Keep resources in a structured directory hierarchy")
log_info("3. Use security settings to limit resource access")
log_info("4. For mod support, create custom path remapping in your game code")
# Example of custom path handling
log_info("\nExample of custom path handling:")
var code := """
# Custom path resolver for mod resources
func resolve_mod_path(path: String, mod_id: String) -> String:
if path.begins_with("mod://"):
# Transform mod:// protocol to user://mods/{mod_id}/
return path.replace("mod://", "user://mods/" + mod_id + "/")
return path
# Example usage with YAML
func load_mod_config(mod_id: String) -> Dictionary:
var yaml_text = FileAccess.open("user://mods/" + mod_id + "/config.yaml", FileAccess.READ).get_as_text()
var data = YAML.parse(yaml_text).get_data()
# Process all resource paths in the data
for key in data:
if data[key] is String and data[key].begins_with("mod://"):
# Resolve the path
data[key] = resolve_mod_path(data[key], mod_id)
return data
"""
log_code_block(code)
@@ -0,0 +1 @@
uid://csoaahlo0042h
+206
View File
@@ -0,0 +1,206 @@
extends ExampleBase
func _init() -> void:
icon = "🔒"
func run_examples() -> void:
run_example("Default Security Behavior", default_security)
run_example("Custom Security: Allow Path", custom_security_allow_path)
run_example("Wildcard Path Patterns", wildcard_paths)
run_example("Blocking Resource Types", block_type)
run_example("Clearing Type Restrictions", clear_type_restrictions)
run_example("Resetting Security", reset_security)
run_example("Security Best Practices", security_best_practices)
func default_security() -> void:
log_info("Testing default security settings...")
# Default security should block Script and GDExtension
var yaml_text := """
dangerous: !Resource 'res://addons/yaml/examples/classes/my_custom_class.gd'
"""
log_code_block(yaml_text)
var result := YAML.parse(yaml_text)
if result.has_error():
log_success("Default security correctly blocked Script resource")
log_info("Error message: " + result.get_error())
return
log_error("Security failed to block unsafe resource")
func custom_security_allow_path() -> void:
log_info("Creating custom security policy...")
var security := YAML.create_security()
# Allow only textures from a specific path
security.allow_path("res://addons/yaml/examples/assets", ["Texture2D"])
log_info("Testing allowed path and type...")
var allowed_yaml := """
texture: !Resource 'res://addons/yaml/icon.svg'
"""
log_code_block(allowed_yaml)
var result := YAML.parse(allowed_yaml, security)
if !result.has_error():
log_success("Correctly allowed texture in permitted path")
else:
log_error("Incorrectly blocked permitted resource: " + result.get_error())
log_info("Testing incorrect path...")
var wrong_path_yaml := """
texture: !Resource 'res://addons/yaml/examples/wrong_path/test.png'
"""
log_code_block(wrong_path_yaml)
result = YAML.parse(wrong_path_yaml, security)
if result.has_error():
log_success("Correctly blocked resource outside allowed path")
log_info("Error message: " + result.get_error())
else:
log_error("Security failed to block resource outside allowed path")
log_info("Testing incorrect type...")
var wrong_type_yaml := """
scene: !Resource 'res://addons/yaml/examples/assets/textures/test.tscn'
"""
log_code_block(wrong_type_yaml)
result = YAML.parse(wrong_type_yaml, security)
if result.has_error():
log_success("Correctly blocked non-texture resource")
log_info("Error message: " + result.get_error())
else:
log_error("Security failed to block non-texture resource")
func wildcard_paths() -> void:
log_info("Testing wildcard path patterns...")
# Create security configuration
var security := YAML.create_security()
# Test single segment wildcard (*)
log_info("Single segment wildcard (*) example:")
security.allow_path("res://addons/yaml/*/assets", ["Texture2D"])
var single_wildcard_yaml := """
texture: !Resource 'res://addons/yaml/icon.svg'
"""
log_code_block(single_wildcard_yaml)
var result := YAML.parse(single_wildcard_yaml, security)
if !result.has_error():
log_success("Single wildcard pattern matched correctly")
else:
log_error("Single wildcard failed: " + result.get_error())
# Test recursive wildcard (**)
log_info("\nRecursive wildcard (**) example:")
security.clear_path_restrictions()
security.allow_path("res://**", ["PackedScene"])
var recursive_wildcard_yaml := """
scene: !Resource 'res://addons/yaml/examples/assets/simple_scene.tscn'
"""
log_code_block(recursive_wildcard_yaml)
result = YAML.parse(recursive_wildcard_yaml, security)
if !result.has_error():
log_success("Recursive wildcard pattern matched correctly")
else:
log_error("Recursive wildcard failed: " + result.get_error())
func block_type() -> void:
log_info("Testing type blocking functionality...")
var security := YAML.create_security()
security.allow_path("res://**") # Allow all paths
security.block_type("PackedScene") # But block all scenes
var blocked_yaml := """
scene: !Resource 'res://addons/yaml/examples/assets/test.tscn'
"""
log_code_block(blocked_yaml)
var result := YAML.parse(blocked_yaml, security)
if result.has_error():
log_success("Correctly blocked resource of blocked type")
log_info("Error message: " + result.get_error())
else:
log_error("Failed to block specified resource type")
func clear_type_restrictions() -> void:
log_info("Testing clearing type restrictions...")
var security := YAML.create_security()
security.allow_path("res://**")
security.clear_type_restrictions() # This removes default blocks on Script and GDExtension
var script_yaml := """
script: !Resource 'res://addons/yaml/examples/classes/my_custom_class.gd'
"""
log_code_block(script_yaml)
log_warning("Note: Allowing scripts can be dangerous with untrusted content")
var result := YAML.parse(script_yaml, security)
if !result.has_error():
log_success("Script resource allowed after clearing restrictions")
else:
log_error("Failed to allow script after clearing restrictions: " + result.get_error())
func reset_security() -> void:
log_info("Testing security reset functionality...")
var security := YAML.create_security()
security.allow_path("res://**")
security.clear_type_restrictions()
log_info("Before reset: All paths allowed, all types allowed")
security.reset() # Should revert to default security
log_info("After reset: Default restrictions should apply")
var script_yaml := """
script: !Resource 'res://addons/yaml/examples/assets/my_custom_class.gd'
"""
log_code_block(script_yaml)
var result := YAML.parse(script_yaml, security)
if result.has_error():
log_success("Script correctly blocked after security reset")
log_info("Error message: " + result.get_error())
else:
log_error("Security reset failed to restore default restrictions")
func security_best_practices() -> void:
log_subheader("Security Best Practices")
log_info("1. Default security blocks Script and GDExtension resources")
log_info("2. Only allow specific paths and types needed by your application")
log_info("3. Use the most specific path patterns possible")
log_info("4. For user content, create a dedicated directory and apply strict type limitations")
log_info("\nExample for mod content security:")
var code := """
# Create security for user mods
var mod_security = YAML.create_security()
# Only allow textures, audio, and text files
mod_security.allow_path("user://mods/**", [
"Texture2D",
"CompressedTexture2D",
"AudioStreamOggVorbis",
"AudioStreamMP3"
])
# Explicitly block potentially dangerous types
mod_security.block_type("Script")
mod_security.block_type("GDExtension")
mod_security.block_type("PackedScene")
# Use this security when loading mod content
var result = YAML.load_file("user://mods/my_mod/config.yaml", mod_security)
"""
log_code_block(code)
@@ -0,0 +1 @@
uid://bdbxqi1f27ndt

Some files were not shown because too many files have changed in this diff Show More