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
+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