395 lines
11 KiB
GDScript
395 lines
11 KiB
GDScript
@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]
|