Files
Dawn-Godot/addons/dialogue_manager/components/code_edit.gd
T
2026-06-11 19:59:31 -05:00

1294 lines
43 KiB
GDScript

@tool
class_name DMCodeEdit extends CodeEdit
signal active_title_change(title: String)
signal error_clicked(line_number: int)
signal external_file_requested(path: String, title: String)
const MUTATION_PREFIXES: PackedStringArray = ["$>", "$>>", "do ", "do! ", "set ", "if ", "elif ", "else if ", "match ", "when "]
const INLINE_MUTATION_PREFIXES: PackedStringArray = ["$> ", "$>> ", "do ", "do! ", "set ", "if ", "elif "]
# A link back to the owner `MainView`
var main_view: Control
# Theme overrides for syntax highlighting, etc
var theme_overrides: Dictionary:
set(value):
theme_overrides = value
syntax_highlighter = DMSyntaxHighlighter.new()
# General UI
add_theme_color_override("font_color", theme_overrides.text_color)
add_theme_color_override("background_color", theme_overrides.background_color)
add_theme_color_override("current_line_color", theme_overrides.current_line_color)
add_theme_font_override("font", get_theme_font("source", "EditorFonts"))
add_theme_font_size_override("font_size", theme_overrides.font_size * theme_overrides.scale)
font_size = round(theme_overrides.font_size)
get:
return theme_overrides
# Any parse errors
var errors: Array:
set(next_errors):
errors = next_errors
for i in range(0, get_line_count()):
var is_error: bool = false
for error in errors:
if error.line_number == i:
is_error = true
mark_line_as_error(i, is_error)
_on_code_edit_caret_changed()
get:
return errors
# The last selection (if there was one) so we can remember it for refocusing
var last_selected_text: String
var font_size: int:
set(value):
font_size = value
add_theme_font_size_override("font_size", font_size * theme_overrides.scale)
get:
return font_size
var WEIGHTED_RANDOM_PREFIX: RegEx = RegEx.create_from_string("^\\%[\\d.]+\\s")
var STATIC_REGEX: RegEx = RegEx.create_from_string("^static var (?<property>[a-zA-Z_0-9]+)(:\\s?(?<type>[a-zA-Z_0-9]+))?")
var STATIC_CONTENT_REGEX: RegEx = RegEx.create_from_string("static (var|func)")
var compiler_regex: DMCompilerRegEx = DMCompilerRegEx.new()
var _autoloads: Dictionary[String, String] = {}
var _autoload_member_cache: Dictionary[String, Dictionary] = {}
func _ready() -> void:
# Add error gutter
add_gutter(0)
set_gutter_type(0, TextEdit.GUTTER_TYPE_ICON)
# Add comment delimiter
if not has_comment_delimiter("#"):
add_comment_delimiter("#", "", true)
syntax_highlighter = DMSyntaxHighlighter.new()
# Keep track of any autoloads
ProjectSettings.settings_changed.connect(_on_project_settings_changed)
_on_project_settings_changed()
func _gui_input(event: InputEvent) -> void:
# Handle shortcuts that come from the editor
if event is InputEventKey and event.is_pressed():
var shortcut: String = DMPlugin.get_editor_shortcut(event)
match shortcut:
"toggle_comment":
toggle_comment()
get_viewport().set_input_as_handled()
"delete_line":
delete_current_line()
get_viewport().set_input_as_handled()
"move_up":
move_line(-1)
get_viewport().set_input_as_handled()
"move_down":
move_line(1)
get_viewport().set_input_as_handled()
"text_size_increase":
self.font_size += 1
get_viewport().set_input_as_handled()
"text_size_decrease":
self.font_size -= 1
get_viewport().set_input_as_handled()
"text_size_reset":
self.font_size = theme_overrides.font_size
get_viewport().set_input_as_handled()
"make_bold":
insert_bbcode("[b]", "[/b]")
get_viewport().set_input_as_handled()
"make_italic":
insert_bbcode("[i]", "[/i]")
get_viewport().set_input_as_handled()
elif event is InputEventMouse:
match event.as_text():
"Ctrl+Mouse Wheel Up", "Command+Mouse Wheel Up":
self.font_size += 1
get_viewport().set_input_as_handled()
"Ctrl+Mouse Wheel Down", "Command+Mouse Wheel Down":
self.font_size -= 1
get_viewport().set_input_as_handled()
func _can_drop_data(at_position: Vector2, data) -> bool:
if typeof(data) != TYPE_DICTIONARY: return false
if data.type != "files": return false
var files: PackedStringArray = Array(data.files)
return files.size() > 0
func _drop_data(at_position: Vector2, data: Variant) -> void:
var replace_regex: RegEx = RegEx.create_from_string("[^a-zA-Z_0-9]+")
if typeof(data) == TYPE_STRING: return
var files: PackedStringArray = Array(data.files)
for file: String in files:
# Don't import the file into itself
if file == main_view.current_file_path: continue
if file.get_extension() == "dialogue":
var known_aliases: PackedStringArray = []
var path: String = file.replace("res://", "").replace(".dialogue", "")
# Find the first non-import line in the file to add our import
var lines: PackedStringArray = text.split("\n")
for i: int in range(0, lines.size()):
if lines[i].begins_with("import "):
var found: RegExMatch = compiler_regex.IMPORT_REGEX.search(lines[i])
if found:
known_aliases.append(found.strings[found.names.prefix])
else:
var alias: String = ""
var bits: PackedStringArray = replace_regex.sub(path, "|", true).split("|")
bits.reverse()
for end: int in range(1, bits.size() + 1):
alias = "_".join(bits.slice(0, end))
if not alias in known_aliases:
break
insert_line_at(i, "import \"%s\" as %s\n" % [file, alias])
set_caret_line(i)
break
else:
var cursor: Vector2 = get_line_column_at_pos(at_position)
if cursor.x > -1 and cursor.y > -1:
set_cursor(cursor)
remove_secondary_carets()
var resource: Resource = load(file)
# If the dropped file is an audio stream then assume it's a voice reference
if is_instance_of(resource, AudioStream):
var current_voice_regex: RegEx = RegEx.create_from_string("\\[#voice=.+\\]")
var path: String = ResourceUID.call("path_to_uid", file) if ResourceUID.has_method("path_to_uid") else file
var line_text: String = get_line(cursor.y)
var voice_text: String = "[#voice=%s]" % [path]
if current_voice_regex.search(line_text):
set_line(cursor.y, current_voice_regex.replace(get_line(cursor.y), voice_text))
else:
insert_text(" " + voice_text, cursor.y, line_text.length())
# Other wise it's just a file reference
else:
insert_text("\"%s\"" % file, cursor.y, cursor.x)
grab_focus()
func _request_code_completion(force: bool) -> void:
var cursor: Vector2 = get_cursor()
var current_line: String = get_line(cursor.y)
_add_jump_completions(current_line, cursor)
_add_character_name_completions(current_line)
_add_mutation_completions(current_line, cursor)
update_code_completion_options(true)
if get_code_completion_options().size() == 0:
cancel_code_completion()
func _filter_code_completion_candidates(candidates: Array) -> Array:
# Not sure why but if this method isn't overridden then all completions are wrapped in quotes.
return candidates
func _confirm_code_completion(replace: bool) -> void:
var completion: Dictionary = get_code_completion_option(get_code_completion_selected_index())
begin_complex_operation()
# Delete any part of the text that we've already typed
for i: int in range(0, completion.display_text.length() - completion.insert_text.length()):
backspace()
# Insert the whole match
insert_text_at_caret(completion.display_text)
end_complex_operation()
if completion.display_text.ends_with("()"):
set_cursor(get_cursor() - Vector2.RIGHT)
# Close the autocomplete menu on the next tick
call_deferred("cancel_code_completion")
#region Completion Helpers
# Add completions for jump targets (=> and =><).
func _add_jump_completions(current_line: String, cursor: Vector2) -> void:
if not ("=> " in current_line or "=>< " in current_line): return
if cursor.x <= current_line.find("=>"): return
var prompt: String = current_line.split("=>")[1]
if prompt.begins_with("< "):
prompt = prompt.substr(2)
else:
prompt = prompt.substr(1)
if "=> " in current_line:
if _matches_prompt(prompt, "end"):
add_code_completion_option(CodeEdit.KIND_CLASS, "END", "END".substr(prompt.length()), theme_overrides.text_color, get_theme_icon("Stop", "EditorIcons"))
if _matches_prompt(prompt, "end!"):
add_code_completion_option(CodeEdit.KIND_CLASS, "END!", "END!".substr(prompt.length()), theme_overrides.text_color, get_theme_icon("Stop", "EditorIcons"))
# Get all titles, including those in imports
for title: String in DMCompiler.get_titles_in_text(text, main_view.current_file_path):
# Ignore any imported titles that aren't resolved to human readable.
if title.to_int() > 0:
continue
elif "/" in title:
var bits: PackedStringArray = title.split("/")
if _matches_prompt(prompt, bits[0]) or _matches_prompt(prompt, bits[1]):
add_code_completion_option(CodeEdit.KIND_CLASS, title, title.substr(prompt.length()), theme_overrides.text_color, get_theme_icon("CombineLines", "EditorIcons"))
elif _matches_prompt(prompt, title):
add_code_completion_option(CodeEdit.KIND_CLASS, title, title.substr(prompt.length()), theme_overrides.text_color, get_theme_icon("ArrowRight", "EditorIcons"))
# Add completions for character names at the start of dialogue lines.
func _add_character_name_completions(current_line: String) -> void:
# Ignore names on mutation lines
for prefix: String in MUTATION_PREFIXES:
if current_line.strip_edges().begins_with(prefix):
return
var name_so_far: String = WEIGHTED_RANDOM_PREFIX.sub(current_line.strip_edges(), "")
if name_so_far == "": return
var names: PackedStringArray = get_character_names(name_so_far)
for character_name: String in names:
add_code_completion_option(CodeEdit.KIND_CLASS, character_name + ": ", character_name.substr(name_so_far.length()) + ": ", theme_overrides.text_color, get_theme_icon("Sprite2D", "EditorIcons"))
# Add state/mutation completions.
func _add_mutation_completions(current_line: String, cursor: Vector2) -> void:
# Check for inline mutation context first (e.g., "Nathan: Hello [$> SomeGlobal.")
var inline_context: Dictionary = _get_inline_mutation_context(current_line, cursor.x)
var mutation_expression: String = ""
var is_inline_mutation: bool = not inline_context.is_empty()
var is_using_line: bool = false
if is_inline_mutation:
mutation_expression = inline_context.get("expression", "")
else:
# Match autoloads on full mutation lines (MUTATION_PREFIXES + "using ")
for prefix in MUTATION_PREFIXES + PackedStringArray(["using "]):
if current_line.strip_edges().begins_with(prefix) and cursor.x > current_line.find(prefix):
mutation_expression = current_line.substr(0, cursor.x).strip_edges().substr(3)
is_using_line = current_line.strip_edges().begins_with("using ")
break
if mutation_expression == "" and not is_inline_mutation:
return
# Find the last token (the part being typed)
var possible_prompt: String = mutation_expression.reverse()
possible_prompt = possible_prompt.substr(0, possible_prompt.find(" "))
possible_prompt = possible_prompt.substr(0, possible_prompt.find("("))
possible_prompt = possible_prompt.reverse()
var segments: PackedStringArray = possible_prompt.split(".")
var auto_completes: Array[Dictionary] = []
if segments.size() == 1:
# Suggest autoloads and state shortcuts
auto_completes = _get_autoload_completions(segments[0])
elif not is_using_line:
if not segments[0] in _autoloads.keys():
# See if the first segment is a property of a shortcut
var shortcut: String = _find_shortcut_with_member(segments[0])
if not shortcut.is_empty():
segments.insert(0, shortcut)
# Suggest members of an autoload or nested property
auto_completes = _get_member_completions(segments)
var prompt: String = segments[-1].to_lower()
# Add true/false
if prompt.length() > 1:
var icon: Texture2D = _get_icon_for_type("keyword")
var color: Color = theme_overrides.conditions_color
if "true".contains(prompt):
add_code_completion_option(CodeEdit.KIND_CONSTANT, "true", "true".substr(prompt.length()), color, icon)
if "false".contains(prompt):
add_code_completion_option(CodeEdit.KIND_CONSTANT, "false", "false".substr(prompt.length()), color, icon)
# Remove duplicates
var unique_auto_completes: PackedStringArray = []
auto_completes = auto_completes.filter(func(auto_complete: Dictionary) -> bool:
if unique_auto_completes.has(auto_complete.text): return false
unique_auto_completes.append(auto_complete.text)
return true
)
auto_completes.sort_custom(func(a, b):
return a.text.to_lower().similarity(prompt) > b.text.to_lower().similarity(prompt)
)
for auto_complete: Dictionary in auto_completes:
var icon: Texture2D = _get_icon_for_type(auto_complete.type)
var display_text: String = auto_complete.text
if auto_complete.type == "method":
display_text += "()"
var insert: String = display_text.substr(auto_complete.prompt.length())
add_code_completion_option(CodeEdit.KIND_CLASS, display_text, insert, theme_overrides.text_color, icon)
# Find the shortcut that a member name belongs to.
func _find_shortcut_with_member(member_name: String) -> String:
for autoload: String in _get_state_shortcuts():
for member: Dictionary in _get_members_for_base_script(autoload):
if member.name == member_name:
return autoload
return ""
# Get completions for autoload names and state shortcut members.
func _get_autoload_completions(prompt: String) -> Array[Dictionary]:
var completions: Array[Dictionary] = []
for autoload: String in _autoloads.keys():
if _matches_prompt(prompt, autoload):
completions.append({ prompt = prompt, text = autoload, type = "script" })
for autoload: String in _get_state_shortcuts():
for member: Dictionary in _get_members_for_base_script(autoload):
if _matches_prompt(prompt, member.name):
completions.append({ prompt = prompt, text = member.name, type = member.type })
return completions
# Get completions for members of an autoload or nested property chain.
func _get_member_completions(segments: PackedStringArray) -> Array[Dictionary]:
var completions: Array[Dictionary] = []
var prompt: String = segments[-1]
var members: Array[Dictionary] = []
if segments.size() == 2:
# Direct autoload property access (e.g., "SomeGlobal.property")
members = _get_members_for_base_script(segments[0])
else:
# Nested property access (e.g., "SomeGlobal.a_class_property.nested")
var chain_segments: PackedStringArray = segments.slice(0, segments.size() - 1)
var resolved_script: Variant = _resolve_script_for_property_chain(chain_segments)
if resolved_script != null:
members = _get_members_for_script(resolved_script)
for member: Dictionary in members:
if _matches_prompt(prompt, member.name):
completions.append({ prompt = prompt, text = member.name, type = member.type })
return completions
# Get the appropriate icon for a member type.
func _get_icon_for_type(type: String) -> Texture2D:
match type:
"keyword":
return get_theme_icon("CodeHighlighter", "EditorIcons")
"script":
return get_theme_icon("Script", "EditorIcons")
"property":
return get_theme_icon("MemberProperty", "EditorIcons")
"method":
return get_theme_icon("MemberMethod", "EditorIcons")
"signal":
return get_theme_icon("MemberSignal", "EditorIcons")
"constant":
return get_theme_icon("MemberConstant", "EditorIcons")
"enum":
return get_theme_icon("Enum", "EditorIcons")
return null
#endregion
#region Cursor Helpers
## Get the current caret position as a Vector2 (x=column, y=line).
func get_cursor() -> Vector2:
return Vector2(get_caret_column(), get_caret_line())
## Set the caret position from a Vector2 (x=column, y=line).
func set_cursor(from_cursor: Vector2) -> void:
set_caret_line(from_cursor.y, false)
set_caret_column(from_cursor.x, false)
# Check if a prompt fuzzy-matches a candidate.
func _matches_prompt(prompt: String, candidate: String) -> bool:
if prompt.length() > candidate.length(): return false
if prompt.is_empty(): return true
# Fuzzy match characters in order
candidate = candidate.to_lower()
var next_index: int = 0
for char: String in prompt.to_lower():
next_index = candidate.find(char, next_index) + 1
if next_index == 0:
return false
return true
#endregion
#region Autoload and Script Helpers
# Get autoload shortcuts from settings and "using" clauses.
func _get_state_shortcuts() -> PackedStringArray:
# Get any shortcuts defined in settings
var shortcuts: PackedStringArray = DMSettings.get_setting(DMSettings.STATE_AUTOLOAD_SHORTCUTS, [])
# Check for "using" clauses
for line: String in text.split("\n"):
var found: RegExMatch = compiler_regex.USING_REGEX.search(line)
if found:
shortcuts.append(found.strings[found.names.state])
# Check for any other script sources
for extra_script_source: String in DMSettings.get_setting(DMSettings.EXTRA_AUTO_COMPLETE_SCRIPT_SOURCES, []):
if extra_script_source:
shortcuts.append(extra_script_source)
return shortcuts
# Get all members (methods, properties, signals, constants) for an autoload.
func _get_members_for_base_script(base_script_name: String) -> Array[Dictionary]:
# Debounce method list lookups
if _autoload_member_cache.has(base_script_name) \
and _autoload_member_cache.get(base_script_name).get("at") > Time.get_ticks_msec() - 10000:
return _autoload_member_cache.get(base_script_name).get("members")
if not _autoloads.has(base_script_name) \
and not base_script_name.begins_with("res://") \
and not base_script_name.begins_with("uid://"):
return []
var autoload: Variant = load(_autoloads.get(base_script_name, base_script_name))
if autoload is PackedScene:
var node: Node = autoload.instantiate()
autoload = node.get_script()
node.free()
if autoload == null: return []
var script: Script = autoload if autoload is Script else autoload.get_script()
if not is_instance_valid(script): return []
var members: Array[Dictionary] = _get_members_for_script(script)
_autoload_member_cache[base_script_name] = {
at = Time.get_ticks_msec(),
members = members
}
return members
# Get all members (methods, properties, signals, constants) for a Script.
func _get_members_for_script(script: Variant) -> Array[Dictionary]:
var members: Array[Dictionary] = []
# Its an enum:
if script is Dictionary:
for key: String in script.keys():
members.append({
name = key,
type = "enum"
})
return members
# Otherwise its a script
if not is_instance_valid(script): return []
if script.resource_path.is_empty() or script.resource_path.ends_with(".gd"):
for m: Dictionary in script.get_script_method_list():
if not m.name.begins_with("@"):
members.append({
name = m.name,
type = "method"
})
for m: Dictionary in script.get_script_property_list():
if not m.name.ends_with(".gd") and not m.name.contains("Built-in"):
members.append({
name = m.name,
type = "property",
"class_name" = m.get("class_name", "")
})
for m: Dictionary in script.get_script_signal_list():
members.append({
name = m.name,
type = "signal"
})
for c: String in script.get_script_constant_map():
members.append({
name = c,
type = "constant"
})
# Check for static properties
for line: String in script.source_code.split("\n"):
var matching: RegExMatch = STATIC_REGEX.search(line)
if matching:
members.append({
name = matching.strings[matching.names.property],
type = "property"
})
elif script.resource_path.ends_with(".cs"):
var dotnet: RefCounted = load(DMPlugin.get_plugin_path() + "/DialogueManager.cs").new()
for m: Dictionary in dotnet.GetMembersForScript(script):
members.append(m)
return members
# Get the Script for a given class name.
func _get_script_for_class_name(class_name_to_find: String) -> Script:
if class_name_to_find == "": return null
for class_data: Dictionary in ProjectSettings.get_global_class_list():
if class_data.get(&"class") == class_name_to_find:
return load(class_data.path)
return null
# Get method info (args, return type) for a method in a Script.
func _get_method_info_from_script(script: Script, method_name: String) -> Dictionary:
if not is_instance_valid(script): return {}
if script.resource_path.ends_with(".gd"):
for m: Dictionary in script.get_script_method_list():
if m.name == method_name:
return m
elif script.resource_path.ends_with(".cs"):
var dotnet: RefCounted = load(DMPlugin.get_plugin_path() + "/DialogueManager.cs").new()
for m: Dictionary in dotnet.GetMembersForScript(script):
if m.get("name") == method_name and m.get("type") == "method":
return m
return {}
# Format method arguments into a hint string for display.
func _format_method_hint(method_info: Dictionary) -> String:
if method_info.is_empty(): return ""
var args: Array = method_info.get("args", [])
if args.size() == 0: return ""
var hint_parts: PackedStringArray = []
for arg: Dictionary in args:
var arg_name: String = arg.get("name", "")
var arg_type: int = arg.get("type", TYPE_NIL)
var arg_class_name: String = arg.get("class_name", "")
var type_name: String = ""
if arg_class_name != "":
type_name = arg_class_name
elif arg_type != TYPE_NIL:
type_name = type_string(arg_type)
if type_name != "":
hint_parts.append("%s: %s" % [arg_name, type_name])
else:
hint_parts.append(arg_name)
return ", ".join(hint_parts)
#endregion
#region Symbol Resolution Helpers
# Find the line number where a member is defined in a script's source code.
func _find_definition_in_script(script: Script, member_name: String) -> int:
if not is_instance_valid(script): return -1
var lines: PackedStringArray = script.source_code.split("\n")
if script is GDScript:
# Try to find the line in a GDScript file.
var method_regex: RegEx = RegEx.create_from_string("^\\s*func\\s+" + member_name + "\\s*\\(")
var property_regex: RegEx = RegEx.create_from_string("^(@.*\\s)?\\s*var\\s+" + member_name + "\\s*[:\\s=]")
var signal_regex: RegEx = RegEx.create_from_string("^\\s*signal\\s+" + member_name + "\\s*[\\(\\s]")
var const_regex: RegEx = RegEx.create_from_string("^\\s*const\\s+" + member_name + "\\s*[:\\s=]")
var enum_regex: RegEx = RegEx.create_from_string("^\\s*enum\\s+" + member_name + "[\\s$]")
var inner_class_regex: RegEx = RegEx.create_from_string("^\\s*class\\s+" + member_name + ":")
for i: int in range(lines.size()):
var line: String = lines[i]
if method_regex.search(line) \
or property_regex.search(line) \
or signal_regex.search(line) \
or const_regex.search(line) \
or enum_regex.search(line) \
or inner_class_regex.search(line):
# Editor line numbers start at 1
return i + 1
# Does the script extend another one?
var extends_regex: RegEx = RegEx.create_from_string("extends (?<extends>.*)")
var found_extends: RegExMatch = extends_regex.search(script.source_code)
if found_extends:
var extends_name: String = found_extends.get_string(found_extends.names.extends)
if not "\"" in extends_name:
script = _get_script_for_class_name(extends_name)
return _find_definition_in_script(script, member_name)
elif script.resource_path.ends_with(".cs"):
# Try to find the line in a C# file.
var method_regex: RegEx = RegEx.create_from_string("^\\s*public\\s+.*?" + member_name + "\\s*\\(")
var property_regex: RegEx = RegEx.create_from_string("^\\s*(\\[Export\\] )?public\\s+.*?" + member_name)
var const_regex: RegEx = RegEx.create_from_string("^\\s*const\\s+.*?" + member_name)
var enum_regex: RegEx = RegEx.create_from_string("^\\s*public enum\\s+" + member_name)
for i: int in range(lines.size()):
var line: String = lines[i]
if method_regex.search(line) \
or property_regex.search(line) \
or const_regex.search(line) \
or enum_regex.search(line):
# Editor line numbers start at 1
return i + 1
return -1
# Resolve the symbol at a given position in a mutation line for definition lookup.
func _resolve_mutation_symbol_at_position(line_text: String, column: int) -> Dictionary:
if not _is_in_mutation_context(line_text, column):
return {}
var symbol: String = get_word_at_pos(get_local_mouse_pos())
if symbol.is_empty(): return {}
# Find the full chain by looking backwards from the token start for dots and identifiers
var token_start: int = column
while token_start > 0 and line_text[token_start - 1].is_valid_ascii_identifier():
token_start -= 1
var chain_start: int = token_start
while chain_start > 0:
var prev_char: String = line_text[chain_start - 1]
if prev_char == ".":
chain_start -= 1
# Continue backwards to get the identifier before the dot
while chain_start > 0 and line_text[chain_start - 1].is_valid_ascii_identifier():
chain_start -= 1
else:
break
var full_chain: String = line_text.substr(chain_start, token_start + symbol.length() - chain_start)
# Remove any trailing parentheses content
if "(" in full_chain:
full_chain = full_chain.substr(0, full_chain.find("("))
var segments: PackedStringArray = full_chain.split(".")
# Check if it starts with an autoload
if not segments[0] in _autoloads.keys():
var shortcut: String = _find_shortcut_with_member(segments[0])
if shortcut.is_empty():
return {}
else:
segments.insert(0, shortcut)
# The symbol we clicked on is the last segment
var member_name: String = segments[-1]
# Resolve the script that contains this member
var target_script: Variant = null
if segments.size() == 1 and segments[0] in _autoloads.keys():
member_name = "class_name"
var target: Variant = load(_autoloads.get(segments[0]))
if target is PackedScene:
var node: Node = target.instantiate()
target = node.get_script()
node.free()
target_script = target if target is Script else target.get_script()
else:
var object_segments: PackedStringArray = segments.slice(0, segments.size() - 1)
target_script = _resolve_script_for_property_chain(object_segments)
if target_script == null:
return {}
elif target_script is Dictionary:
return {
"script": _resolve_script_for_property_chain(segments.slice(0, -2)),
"member_name": segments.slice(0, -1)[segments.size() - 2],
"symbol": symbol
}
return {
"script": target_script,
"member_name": member_name,
"symbol": symbol
}
# Update the code hint to show method parameter information.
func _update_code_hint() -> void:
var cursor: Vector2 = get_cursor()
var current_line: String = get_line(cursor.y)
var text_before_cursor: String = current_line.substr(0, cursor.x)
# Check if we're in a mutation context (inline or full line)
var inline_context: Dictionary = _get_inline_mutation_context(current_line, cursor.x)
if not _is_in_mutation_context(current_line, cursor.x):
set_code_hint("")
return
# For inline mutations, scope to the bracket content
var expression_text: String = text_before_cursor
if not inline_context.is_empty():
var bracket_start: int = inline_context.get("bracket_start", 0)
expression_text = current_line.substr(bracket_start + 1, cursor.x - bracket_start - 1)
# Check if cursor is inside parentheses by counting unmatched opening parens
var paren_depth: int = 0
var last_open_parenthesis_pos: int = -1
for i: int in range(expression_text.length()):
if expression_text[i] == "(":
paren_depth += 1
last_open_parenthesis_pos = i
elif expression_text[i] == ")":
paren_depth -= 1
if paren_depth <= 0 or last_open_parenthesis_pos == -1:
set_code_hint("")
return
# Extract the expression before the opening parenthesis
var expression_before_parenthesis: String = expression_text.substr(0, last_open_parenthesis_pos).strip_edges()
# Find the method chain (last token before the paren)
var method_chain: String = ""
for i: int in range(expression_before_parenthesis.length() - 1, -1, -1):
var c: String = expression_before_parenthesis[i]
if c == " " or c == "(" or c == "," or c == "=" or c == ">" or c == "!":
method_chain = expression_before_parenthesis.substr(i + 1)
break
if i == 0:
method_chain = expression_before_parenthesis
if method_chain == "":
set_code_hint("")
return
# Parse the method chain into segments
var segments: PackedStringArray = method_chain.split(".")
if segments.is_empty():
set_code_hint("")
return
# The last segment is the method name
var method_name: String = segments[-1]
# Check if it starts with an autoload
if not segments[0] in _autoloads.keys():
var shortcut: String = _find_shortcut_with_member(segments[0])
if shortcut.is_empty():
set_code_hint("")
return
else:
segments.insert(0, shortcut)
# Resolve the script for the object the method is called on
var object_segments: PackedStringArray = segments.slice(0, segments.size() - 1)
var target_script: Variant = _resolve_script_for_property_chain(object_segments)
if target_script == null or not target_script is Script:
set_code_hint("")
return
# Get the method info and format the hint
var method_info: Dictionary = _get_method_info_from_script(target_script, method_name)
var hint: String = _format_method_hint(method_info)
set_code_hint(hint)
#endregion
#region Mutation Context Helpers
# Get the inline mutation context if the cursor is inside an inline mutation bracket.
# Returns a dictionary with "expression" key containing the text to autocomplete,
# or an empty dictionary if not in an inline mutation context.
func _get_inline_mutation_context(line: String, cursor_x: int) -> Dictionary:
# Find all bracket positions and determine if cursor is inside one
var bracket_depth: int = 0
var bracket_start: int = -1
var bracket_content_start: int = -1
for i: int in range(line.length()):
if i >= cursor_x:
break
if line[i] == "[":
bracket_depth += 1
if bracket_depth == 1:
bracket_start = i
bracket_content_start = i + 1
elif line[i] == "]":
bracket_depth -= 1
if bracket_depth == 0:
bracket_start = -1
bracket_content_start = -1
# Not inside brackets
if bracket_start == -1 or bracket_content_start == -1:
return {}
# Get the content inside the brackets up to cursor
var bracket_content: String = line.substr(bracket_content_start, cursor_x - bracket_content_start)
# Check if this is a mutation tag
for prefix: String in INLINE_MUTATION_PREFIXES:
if bracket_content.begins_with(prefix):
# Return the expression part (after the tag)
var expression: String = bracket_content.substr(prefix.length())
return { "expression": expression, "bracket_start": bracket_start }
return {}
# Check if the cursor is in a mutation context (either inline or full mutation line).
func _is_in_mutation_context(line: String, cursor_x: int) -> bool:
if not _get_inline_mutation_context(line, cursor_x).is_empty():
return true
for prefix: String in MUTATION_PREFIXES:
if line.strip_edges().begins_with(prefix):
return true
return false
# Resolve the Script for a chain of property accesses (e.g., "Autoload.prop1.prop2").
func _resolve_script_for_property_chain(segments: PackedStringArray) -> Variant:
if segments.size() == 0: return null
var autoload: Variant = null
if segments[0].begins_with("uid://") or segments[0].begins_with("res://"):
autoload = load(segments[0])
elif _autoloads.has(segments[0]):
autoload = load(_autoloads.get(segments[0]))
else:
return null
if autoload is PackedScene:
var node: Node = autoload.instantiate()
autoload = node.get_script()
node.free()
elif autoload == null:
return null
elif not autoload is Script:
autoload = autoload.get_script()
var current_script: Variant = autoload
if not is_instance_valid(current_script): return null
if (segments.size() == 1): return current_script
# Walk through each property in the chain (except the last one which is what we're completing)
for i: int in range(1, segments.size()):
var property_name: String = segments[i]
var found_property: bool = false
# Regular properties
for property_info: Dictionary in current_script.get_script_property_list():
if property_info.name == property_name:
var prop_class_name: String = property_info.get("class_name", "")
if prop_class_name != "":
current_script = _get_script_for_class_name(prop_class_name)
if current_script == null:
return null
found_property = true
break
else:
# Property doesn't have a class type, can't go deeper
return null
# Check for inner classes and enums
if not found_property:
for constant: String in current_script.get_script_constant_map():
if constant == property_name:
var constant_value: Variant = current_script.get_script_constant_map().get(constant)
# Inner class
if constant_value is Script:
current_script = constant_value
found_property = true
break
# Enum
if constant_value is Dictionary:
current_script = constant_value
found_property = true
break
else:
# Constant isn't an enum or an inner class
return null
# Static properties. NOTE: Godot doesn't programatically find static properties
# so we have to manually find them.
if not found_property and current_script is Script and current_script.source_code.contains("static var"):
for line: String in current_script.source_code.split("\n"):
var matched: RegExMatch = STATIC_REGEX.search(line)
if matched and matched.strings[matched.names.property] == property_name:
if matched.names.has("type"):
var type: String = matched.strings[matched.names.type]
current_script = _get_script_for_class_name(type)
if current_script == null:
return null
found_property = true
break
else:
return null
if not found_property:
return null
return current_script
#endregion
#region Title and Character Helpers
## Get a list of titles from the current text.
func get_titles() -> PackedStringArray:
var titles: PackedStringArray = PackedStringArray([])
var lines: PackedStringArray = text.split("\n")
for line: String in lines:
if line.strip_edges().begins_with("~ "):
titles.append(line.strip_edges().substr(2))
return titles
## Work out what the next title above the current line is
func check_active_title() -> void:
var line_number: int = get_caret_line()
var lines: PackedStringArray = text.split("\n")
# Look at each line above this one to find the next title line
for i: int in range(line_number, -1, -1):
if lines[i].begins_with("~ "):
active_title_change.emit(lines[i].replace("~ ", ""))
return
active_title_change.emit("")
## Move the caret line to match a given title.
func go_to_title(title: String, create_if_none: bool = false) -> void:
var found_title: bool = false
var lines = text.split("\n")
for i: int in range(0, lines.size()):
if lines[i].strip_edges() == "~ " + title:
found_title = true
set_caret_line(i)
center_viewport_to_caret()
if create_if_none and not found_title:
text += "\n\n\n~ %s\n\n=> END" % [title]
set_caret_line(text.split("\n").size() - 2)
center_viewport_to_caret()
## Get all character names from the dialogue that match the given prefix.
func get_character_names(beginning_with: String) -> PackedStringArray:
if beginning_with.is_empty(): return []
var names: PackedStringArray = []
var lines = text.split("\n")
for line: String in lines:
if line.strip_edges().begins_with("#"): continue # skip comments
if ": " in line:
var character_name: String = WEIGHTED_RANDOM_PREFIX.sub(line.split(": ")[0].strip_edges(), "")
if character_name.is_empty() or character_name in names: continue
if character_name.to_lower()[0] != beginning_with.to_lower()[0]: continue
if not _matches_prompt(beginning_with, character_name): continue
names.append(character_name)
return names
#endregion
#region Text Editing Helpers
## Mark a line as an error or not.
func mark_line_as_error(line_number: int, is_error: bool) -> void:
# Lines display counting from 1 but are actually indexed from 0
line_number -= 1
if line_number < 0: return
if is_error:
set_line_background_color(line_number, theme_overrides.error_line_color)
set_line_gutter_icon(line_number, 0, get_theme_icon("StatusError", "EditorIcons"))
else:
set_line_background_color(line_number, Color(0, 0, 0, 0))
set_line_gutter_icon(line_number, 0, null)
## Insert or wrap some bbcode at the caret/selection.
func insert_bbcode(open_tag: String, close_tag: String = "") -> void:
if close_tag == "":
insert_text_at_caret(open_tag)
grab_focus()
else:
var selected_text: String = get_selected_text()
insert_text_at_caret("%s%s%s" % [open_tag, selected_text, close_tag])
grab_focus()
set_caret_column(get_caret_column() - close_tag.length())
## Insert text at current caret position. Moves caret down 1 line if not "=> END".
func insert_text_at_cursor(text_to_insert: String) -> void:
if text_to_insert != "=> END":
insert_text_at_caret(text_to_insert + "\n")
set_caret_line(get_caret_line() + 1)
else:
insert_text_at_caret(text_to_insert)
grab_focus()
## Toggle the selected lines as comments.
func toggle_comment() -> void:
begin_complex_operation()
var comment_delimiter: String = delimiter_comments[0]
var is_first_line: bool = true
var will_comment: bool = true
var selections: Array = []
var line_offsets: Dictionary = {}
for caret_index in range(0, get_caret_count()):
var from_line: int = get_caret_line(caret_index)
var from_column: int = get_caret_column(caret_index)
var to_line: int = get_caret_line(caret_index)
var to_column: int = get_caret_column(caret_index)
if has_selection(caret_index):
from_line = get_selection_from_line(caret_index)
to_line = get_selection_to_line(caret_index)
from_column = get_selection_from_column(caret_index)
to_column = get_selection_to_column(caret_index)
selections.append({
from_line = from_line,
from_column = from_column,
to_line = to_line,
to_column = to_column
})
for line_number: int in range(from_line, to_line + 1):
if line_offsets.has(line_number): continue
var line_text: String = get_line(line_number)
# The first line determines if we are commenting or uncommentingg
if is_first_line:
is_first_line = false
will_comment = not line_text.strip_edges().begins_with(comment_delimiter)
# Only comment/uncomment if the current line needs to
if will_comment:
set_line(line_number, comment_delimiter + line_text)
line_offsets[line_number] = 1
elif line_text.begins_with(comment_delimiter):
set_line(line_number, line_text.substr(comment_delimiter.length()))
line_offsets[line_number] = -1
else:
line_offsets[line_number] = 0
for caret_index in range(0, get_caret_count()):
var selection: Dictionary = selections[caret_index]
select(
selection.from_line,
selection.from_column + line_offsets[selection.from_line],
selection.to_line,
selection.to_column + line_offsets[selection.to_line],
caret_index
)
set_caret_column(selection.from_column + line_offsets[selection.from_line], false, caret_index)
end_complex_operation()
text_set.emit()
text_changed.emit()
## Remove the current line.
func delete_current_line() -> void:
var cursor: Vector2 = get_cursor()
if get_line_count() == 1:
select_all()
elif cursor.y == 0:
select(0, 0, 1, 0)
else:
select(cursor.y - 1, get_line_width(cursor.y - 1), cursor.y, get_line_width(cursor.y))
delete_selection()
text_changed.emit()
## Move the selected lines up or down.
func move_line(offset: int) -> void:
offset = clamp(offset, -1, 1)
var starting_scroll: float = scroll_vertical
var cursor: Vector2 = get_cursor()
var reselect: bool = false
var from: int = cursor.y
var to: int = cursor.y
if has_selection():
reselect = true
from = get_selection_from_line()
to = get_selection_to_line()
var lines: PackedStringArray = text.split("\n")
# Prevent the lines from being out of bounds
if from + offset < 0 or to + offset >= lines.size(): return
var target_from_index: int = from - 1 if offset == -1 else to + 1
var target_to_index: int = to if offset == -1 else from
var line_to_move: String = lines[target_from_index]
lines.remove_at(target_from_index)
lines.insert(target_to_index, line_to_move)
text = "\n".join(lines)
cursor.y += offset
set_cursor(cursor)
from += offset
to += offset
if reselect:
select(from, 0, to, get_line_width(to))
text_changed.emit()
scroll_vertical = starting_scroll + offset
#endregion
#region Signals
func _on_project_settings_changed() -> void:
_autoloads = {}
# Add any actual autoloads
var project = ConfigFile.new()
project.load("res://project.godot")
if project.has_section("autoload"):
for autoload: String in project.get_section_keys("autoload"):
if autoload != "DialogueManager":
_autoloads[autoload] = project.get_value("autoload", autoload).substr(1)
# Add project-defined classes if they contain static properties or methods
var plugin_path: String = DMPlugin.get_plugin_path()
if not plugin_path.is_empty():
for script_info: Dictionary in ProjectSettings.get_global_class_list():
if not script_info.path.begins_with(plugin_path):
var script: Script = load(script_info.path)
var static_match: RegExMatch = STATIC_CONTENT_REGEX.search(script.source_code)
if static_match:
_autoloads[script_info.class] = script_info.path
func _on_code_edit_symbol_validate(symbol: String) -> void:
if symbol.begins_with("res://") and symbol.ends_with(".dialogue"):
set_symbol_lookup_word_as_valid(true)
return
for title: String in get_titles():
if symbol == title:
set_symbol_lookup_word_as_valid(true)
return
# Check if it's a mutation line symbol
var cursor: Vector2 = get_line_column_at_pos(get_local_mouse_pos())
var line_text: String = get_line(cursor.y)
var symbol_info: Dictionary = _resolve_mutation_symbol_at_position(line_text, cursor.x)
if not symbol_info.is_empty() and symbol_info.get("symbol") == symbol:
var script: Script = symbol_info.get("script")
var member_name: String = symbol_info.get("member_name")
if member_name == "class_name":
set_symbol_lookup_word_as_valid(true)
return
else:
var line_number: int = _find_definition_in_script(script, member_name)
if line_number > 0:
set_symbol_lookup_word_as_valid(true)
return
set_symbol_lookup_word_as_valid(false)
func _on_code_edit_symbol_lookup(symbol: String, line: int, column: int) -> void:
if symbol.begins_with("res://") and symbol.ends_with(".dialogue"):
external_file_requested.emit(symbol, "")
return
# Check if it's a title
for title: String in get_titles():
if symbol == title:
go_to_title(symbol)
return
# Check if it's a mutation line symbol
var line_text: String = get_line(line)
var symbol_info: Dictionary = _resolve_mutation_symbol_at_position(line_text, column)
if not symbol_info.is_empty() and symbol_info.get("symbol") == symbol:
var script: Script = symbol_info.get("script")
var member_name: String = symbol_info.get("member_name")
if member_name == "class_name":
EditorInterface.edit_script(script, 1, 0, true)
EditorInterface.set_main_screen_editor.call_deferred("Script")
else:
var line_number: int = _find_definition_in_script(script, member_name)
if line_number > 0:
# Open the script in the editor
EditorInterface.edit_script(script, line_number, 0, true)
EditorInterface.set_main_screen_editor.call_deferred("Script")
return
func _on_code_edit_text_changed() -> void:
request_code_completion(true)
_update_code_hint()
func _on_code_edit_text_set() -> void:
queue_redraw()
func _on_code_edit_caret_changed() -> void:
check_active_title()
last_selected_text = get_selected_text()
_update_code_hint()
func _on_code_edit_gutter_clicked(line: int, gutter: int) -> void:
var line_errors = errors.filter(func(error): return error.line_number == line)
if line_errors.size() > 0:
error_clicked.emit(line)
#endregion