@tool extends CodeEdit signal active_title_change(title: String) signal error_clicked(line_number: int) signal external_file_requested(path: String, title: String) const DialogueSyntaxHighlighter = preload("./code_edit_syntax_highlighter.gd") # A link back to the owner MainView var main_view # Theme overrides for syntax highlighting, etc var theme_overrides: Dictionary: set(value): theme_overrides = value syntax_highlighter = DialogueSyntaxHighlighter.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") 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 = DialogueSyntaxHighlighter.new() func _gui_input(event: InputEvent) -> void: # Handle shortcuts that come from the editor if event is InputEventKey and event.is_pressed(): var shortcut: String = Engine.get_meta("DialogueManagerPlugin").get_editor_shortcut(event) match shortcut: "toggle_comment": toggle_comment() 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() 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).filter(func(f): return f.get_extension() == "dialogue") return files.size() > 0 func _drop_data(at_position: Vector2, data) -> void: var replace_regex: RegEx = RegEx.create_from_string("[^a-zA-Z_0-9]+") var files: PackedStringArray = Array(data.files).filter(func(f): return f.get_extension() == "dialogue") for file in files: # Don't import the file into itself if file == main_view.current_file_path: continue var path = file.replace("res://", "").replace(".dialogue", "") # Find the first non-import line in the file to add our import var lines = text.split("\n") for i in range(0, lines.size()): if not lines[i].begins_with("import "): insert_line_at(i, "import \"%s\" as %s\n" % [file, replace_regex.sub(path, "_", true)]) set_caret_line(i) break func _request_code_completion(force: bool) -> void: var cursor: Vector2 = get_cursor() var current_line: String = get_line(cursor.y) if ("=> " in current_line or "=>< " in current_line) and (cursor.x > current_line.find("=>")): 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 var parser: DialogueManagerParser = DialogueManagerParser.new() parser.prepare(text, main_view.current_file_path, false) for title in parser.titles: if "/" in title: var bits = 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")) update_code_completion_options(true) parser.free() return var name_so_far: String = WEIGHTED_RANDOM_PREFIX.sub(current_line.strip_edges(), "") if name_so_far != "" and name_so_far[0].to_upper() == name_so_far[0]: # Only show names starting with that character var names: PackedStringArray = get_character_names(name_so_far) if names.size() > 0: for name in names: add_code_completion_option(CodeEdit.KIND_CLASS, name + ": ", name.substr(name_so_far.length()) + ": ", theme_overrides.text_color, get_theme_icon("Sprite2D", "EditorIcons")) update_code_completion_options(true) else: 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 = 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 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() # Close the autocomplete menu on the next tick call_deferred("cancel_code_completion") ### Helpers # Get the current caret as a Vector2 func get_cursor() -> Vector2: return Vector2(get_caret_column(), get_caret_line()) # Set the caret from a Vector2 func set_cursor(from_cursor: Vector2) -> void: set_caret_line(from_cursor.y) set_caret_column(from_cursor.x) # Check if a prompt is the start of a string without actually being that string func matches_prompt(prompt: String, matcher: String) -> bool: return prompt.length() < matcher.length() and matcher.to_lower().begins_with(prompt.to_lower()) ## Get a list of titles from the current text func get_titles() -> PackedStringArray: var titles = PackedStringArray([]) var lines = text.split("\n") for line in lines: if line.begins_with("~ "): titles.append(line.substr(2).strip_edges()) return titles ## Work out what the next title above the current line is func check_active_title() -> void: var line_number = get_caret_line() var lines = text.split("\n") # Look at each line above this one to find the next title line for i 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) -> void: var lines = text.split("\n") for i in range(0, lines.size()): if lines[i].strip_edges() == "~ " + title: set_caret_line(i) center_viewport_to_caret() func get_character_names(beginning_with: String) -> PackedStringArray: var names: PackedStringArray = [] var lines = text.split("\n") for line in lines: if ": " in line: var name: String = WEIGHTED_RANDOM_PREFIX.sub(line.split(": ")[0].strip_edges(), "") if not name in names and matches_prompt(beginning_with, name): names.append(name) return names # Mark a line as an error or not func mark_line_as_error(line_number: int, is_error: bool) -> void: 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, theme_overrides.background_color) 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 = 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 # Move Caret down 1 line if not => END func insert_text_at_cursor(text: String) -> void: if text != "=> END": insert_text_at_caret(text+"\n") set_caret_line(get_caret_line()+1) else: insert_text_at_caret(text) 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 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() # Move the selected lines up or down func move_line(offset: int) -> void: offset = clamp(offset, -1, 1) var cursor = 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 := text.split("\n") # We can't move the lines out of bounds if from + offset < 0 or to + offset >= lines.size(): return var target_from_index = from - 1 if offset == -1 else to + 1 var target_to_index = to if offset == -1 else from var line_to_move = 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 from += offset to += offset if reselect: select(from, 0, to, get_line_width(to)) set_cursor(cursor) text_changed.emit() ### Signals 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 in get_titles(): if symbol == title: 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, "") else: go_to_title(symbol) func _on_code_edit_text_changed() -> void: request_code_completion(true) 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() 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)