@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 (?[a-zA-Z_0-9]+)(:\\s?(?[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 (?.*)") 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