@tool extends CodeEdit signal active_title_change(title: String) signal error_clicked(line_number: int) signal external_file_requested(path: String, title: String) # 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.clear_color_regions() syntax_highlighter.clear_keyword_colors() # Imports syntax_highlighter.add_keyword_color("import", theme_overrides.conditions_color) syntax_highlighter.add_keyword_color("as", theme_overrides.conditions_color) # Titles syntax_highlighter.add_color_region("~", "~", theme_overrides.titles_color, true) # Comments syntax_highlighter.add_color_region("#", "##", theme_overrides.comments_color, true) # Conditions syntax_highlighter.add_keyword_color("if", theme_overrides.conditions_color) syntax_highlighter.add_keyword_color("elif", theme_overrides.conditions_color) syntax_highlighter.add_keyword_color("else", theme_overrides.conditions_color) syntax_highlighter.add_keyword_color("while", theme_overrides.conditions_color) syntax_highlighter.add_keyword_color("endif", theme_overrides.conditions_color) syntax_highlighter.add_keyword_color("in", theme_overrides.conditions_color) syntax_highlighter.add_keyword_color("and", theme_overrides.conditions_color) syntax_highlighter.add_keyword_color("or", theme_overrides.conditions_color) syntax_highlighter.add_keyword_color("not", theme_overrides.conditions_color) # Values syntax_highlighter.add_keyword_color("true", theme_overrides.numbers_color) syntax_highlighter.add_keyword_color("false", theme_overrides.numbers_color) syntax_highlighter.number_color = theme_overrides.numbers_color syntax_highlighter.add_color_region("\"", "\"", theme_overrides.strings_color) # Mutations syntax_highlighter.add_keyword_color("do", theme_overrides.mutations_color) syntax_highlighter.add_keyword_color("set", theme_overrides.mutations_color) syntax_highlighter.function_color = theme_overrides.mutations_color syntax_highlighter.member_variable_color = theme_overrides.members_color # Jumps syntax_highlighter.add_color_region("=>", "<=", theme_overrides.jumps_color, true) # Dialogue syntax_highlighter.add_color_region(": ", "::", theme_overrides.text_color, true) # General UI syntax_highlighter.symbol_color = theme_overrides.symbols_color 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 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) func _gui_input(event: InputEvent) -> void: if event is InputEventKey and event.is_pressed(): match event.as_text(): "Ctrl+Equal", "Command+Equal": self.font_size += 1 get_viewport().set_input_as_handled() "Ctrl+Minus", "Command+Minus": self.font_size -= 1 get_viewport().set_input_as_handled() "Ctrl+0", "Command+0": self.font_size = theme_overrides.font_size get_viewport().set_input_as_handled() "Ctrl+K", "Command+K": toggle_comment() get_viewport().set_input_as_handled() "Alt+Up": move_line(-1) get_viewport().set_input_as_handled() "Alt+Down": move_line(1) 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 last_character: String = current_line.substr(cursor.x - 1, 1) var name_so_far: String = 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("~ "): emit_signal("active_title_change", lines[i].replace("~ ", "")) return emit_signal("active_title_change", "0") # 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 = 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(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: # Starting complex operation so that the entire toggle comment can be undone in a single step begin_complex_operation() var caret_count: int = get_caret_count() var caret_offsets: Dictionary = {} for caret_index in caret_count: var caret_line: int = get_caret_line(caret_index) var from: int = caret_line var to: int = caret_line if has_selection(caret_index): from = get_selection_from_line(caret_index) to = get_selection_to_line(caret_index) for line in range(from, to + 1): if line not in caret_offsets: caret_offsets[line] = 0 var line_text: String = get_line(line) var comment_delimiter: String = delimiter_comments[0] var is_line_commented: bool = line_text.begins_with(comment_delimiter) set_line(line, line_text.substr(comment_delimiter.length()) if is_line_commented else comment_delimiter + line_text) caret_offsets[line] += (-1 if is_line_commented else 1) * comment_delimiter.length() emit_signal("lines_edited_from", from, to) # Readjust carets and selection positions after all carets effect have been calculated # Tried making it in the above loop, but that causes a weird behaviour if two carets are on the same line (first caret will move, but not the second one) for caret_index in caret_count: if has_selection(caret_index): var from: int = get_selection_from_line(caret_index) var to: int = get_selection_to_line(caret_index) select(from, get_selection_from_column(caret_index) + caret_offsets[from], to, get_selection_to_column(caret_index) + caret_offsets[to], caret_index) set_caret_column(get_caret_column(caret_index) + caret_offsets[get_caret_line(caret_index)], true, caret_index) end_complex_operation() emit_signal("text_set") emit_signal("text_changed") # 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) emit_signal("text_changed") ### 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"): emit_signal("external_file_requested", 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: emit_signal("error_clicked", line)