@tool extends EditorPlugin const INF_COL : int = 99999 const DEBUGGING : int = 0 # Change to 1 for debugging const CODE_MACRO_PLAY_END : int = 10000 const BREAKERS : Dictionary = { '!': 1, '"': 1, '#': 1, '$': 1, '%': 1, '&': 1, '(': 1, ')': 1, '*': 1, '+': 1, ',': 1, '-': 1, '.': 1, '/': 1, ':': 1, ';': 1, '<': 1, '=': 1, '>': 1, '?': 1, '@': 1, '[': 1, '\\': 1, ']': 1, '^': 1, '`': 1, '\'': 1, '{': 1, '|': 1, '}': 1, '~': 1 } const WHITESPACE: Dictionary = { ' ': 1, ' ': 1, '\n' : 1 } const ALPHANUMERIC: Dictionary = { 'a': 1, 'b': 1, 'c': 1, 'd': 1, 'e': 1, 'f': 1, 'g': 1, 'h': 1, 'i': 1, 'j': 1, 'k': 1, 'l': 1, 'm': 1, 'n': 1, 'o': 1, 'p': 1, 'q': 1, 'r': 1, 's': 1, 't': 1, 'u': 1, 'v': 1, 'w': 1, 'x': 1, 'y': 1, 'z': 1, 'A': 1, 'B': 1, 'C': 1, 'D': 1, 'E': 1, 'F': 1, 'G': 1, 'H': 1, 'I': 1, 'J': 1, 'K': 1, 'L': 1, 'M': 1, 'N': 1, 'O': 1, 'P': 1, 'Q': 1, 'R': 1, 'S': 1, 'T': 1, 'U': 1, 'V': 1, 'W': 1, 'X': 1, 'Y': 1, 'Z': 1, '0': 1, '1': 1, '2': 1, '3': 1, '4': 1, '5': 1, '6': 1, '7': 1, '8': 1, '9': 1, '_': 1 } const LOWER_ALPHA: Dictionary = { 'a': 1, 'b': 1, 'c': 1, 'd': 1, 'e': 1, 'f': 1, 'g': 1, 'h': 1, 'i': 1, 'j': 1, 'k': 1, 'l': 1, 'm': 1, 'n': 1, 'o': 1, 'p': 1, 'q': 1, 'r': 1, 's': 1, 't': 1, 'u': 1, 'v': 1, 'w': 1, 'x': 1, 'y': 1, 'z': 1 } const SYMBOLS = { "(": ")", ")": "(", "[": "]", "]": "[", "{": "}", "}": "{", "<": ">", ">": "<", '"': '"', "'": "'" } enum { MOTION, OPERATOR, OPERATOR_MOTION, ACTION, } enum Context { NORMAL, VISUAL, } var the_key_map : Array[Dictionary] = [ # Move { "keys": ["H"], "type": MOTION, "motion": "move_by_characters", "motion_args": { "forward": false } }, { "keys": ["L"], "type": MOTION, "motion": "move_by_characters", "motion_args": { "forward": true } }, { "keys": ["J"], "type": MOTION, "motion": "move_by_lines", "motion_args": { "forward": true, "line_wise": true } }, { "keys": ["K"], "type": MOTION, "motion": "move_by_lines", "motion_args": { "forward": false, "line_wise": true } }, { "keys": ["Shift+Equal"], "type": MOTION, "motion": "move_by_lines", "motion_args": { "forward": true, "to_first_char": true } }, { "keys": ["Minus"], "type": MOTION, "motion": "move_by_lines", "motion_args": { "forward": false, "to_first_char": true } }, { "keys": ["Shift+4"], "type": MOTION, "motion": "move_to_end_of_line", "motion_args": { "inclusive": true } }, { "keys": ["Shift+6"], "type": MOTION, "motion": "move_to_first_non_white_space_character" }, { "keys": ["0"], "type": MOTION, "motion": "move_to_start_of_line" }, { "keys": ["Shift+H"], "type": MOTION, "motion": "move_to_top_line", "motion_args": { "to_jump_list": true } }, { "keys": ["Shift+L"], "type": MOTION, "motion": "move_to_bottom_line", "motion_args": { "to_jump_list": true } }, { "keys": ["Shift+M"], "type": MOTION, "motion": "move_to_middle_line", "motion_args": { "to_jump_list": true } }, { "keys": ["G", "G"], "type": MOTION, "motion": "move_to_line_or_edge_of_document", "motion_args": { "forward": false, "to_jump_list": true } }, { "keys": ["Shift+G"], "type": MOTION, "motion": "move_to_line_or_edge_of_document", "motion_args": { "forward": true, "to_jump_list": true } }, { "keys": ["Ctrl+F"], "type": MOTION, "motion": "move_by_page", "motion_args": { "forward": true } }, { "keys": ["Ctrl+B"], "type": MOTION, "motion": "move_by_page", "motion_args": { "forward": false } }, { "keys": ["Ctrl+D"], "type": MOTION, "motion": "move_by_scroll", "motion_args": { "forward": true } }, { "keys": ["Ctrl+U"], "type": MOTION, "motion": "move_by_scroll", "motion_args": { "forward": false } }, { "keys": ["Shift+BackSlash"], "type": MOTION, "motion": "move_to_column" }, { "keys": ["W"], "type": MOTION, "motion": "move_by_words", "motion_args": { "forward": true, "word_end": false, "big_word": false } }, { "keys": ["Shift+W"], "type": MOTION, "motion": "move_by_words", "motion_args": { "forward": true, "word_end": false, "big_word": true } }, { "keys": ["E"], "type": MOTION, "motion": "move_by_words", "motion_args": { "forward": true, "word_end": true, "big_word": false, "inclusive": true } }, { "keys": ["Shift+E"], "type": MOTION, "motion": "move_by_words", "motion_args": { "forward": true, "word_end": true, "big_word": true, "inclusive": true } }, { "keys": ["B"], "type": MOTION, "motion": "move_by_words", "motion_args": { "forward": false, "word_end": false, "big_word": false } }, { "keys": ["Shift+B"], "type": MOTION, "motion": "move_by_words", "motion_args": { "forward": false, "word_end": false, "big_word": true } }, { "keys": ["G", "E"], "type": MOTION, "motion": "move_by_words", "motion_args": { "forward": false, "word_end": true, "big_word": false } }, { "keys": ["G", "Shift+E"], "type": MOTION, "motion": "move_by_words", "motion_args": { "forward": false, "word_end": true, "big_word": true } }, { "keys": ["Shift+5"], "type": MOTION, "motion": "move_to_matched_symbol", "motion_args": { "inclusive": true, "to_jump_list": true } }, { "keys": ["F", "{char}"], "type": MOTION, "motion": "move_to_next_char", "motion_args": { "forward": true, "inclusive": true } }, { "keys": ["Shift+F", "{char}"], "type": MOTION, "motion": "move_to_next_char", "motion_args": { "forward": false } }, { "keys": ["T", "{char}"], "type": MOTION, "motion": "move_to_next_char", "motion_args": { "forward": true, "stop_before": true, "inclusive": true } }, { "keys": ["Shift+T", "{char}"], "type": MOTION, "motion": "move_to_next_char", "motion_args": { "forward": false, "stop_before": true } }, { "keys": ["Semicolon"], "type": MOTION, "motion": "repeat_last_char_search", "motion_args": {} }, { "keys": ["Shift+8"], "type": MOTION, "motion": "find_word_under_caret", "motion_args": { "forward": true, "to_jump_list": true } }, { "keys": ["Shift+3"], "type": MOTION, "motion": "find_word_under_caret", "motion_args": { "forward": false, "to_jump_list": true } }, { "keys": ["N"], "type": MOTION, "motion": "find_again", "motion_args": { "forward": true, "to_jump_list": true } }, { "keys": ["Shift+N"], "type": MOTION, "motion": "find_again", "motion_args": { "forward": false, "to_jump_list": true } }, { "keys": ["A", "Shift+9"], "type": MOTION, "motion": "text_object", "motion_args": { "inner": false, "object":"(" } }, { "keys": ["A", "Shift+0"], "type": MOTION, "motion": "text_object", "motion_args": { "inner": false, "object":"(" } }, { "keys": ["A", "B"], "type": MOTION, "motion": "text_object", "motion_args": { "inner": false, "object":"(" } }, { "keys": ["A", "BracketLeft"], "type": MOTION, "motion": "text_object", "motion_args": { "inner": false, "object":"[" } }, { "keys": ["A", "BracketRight"], "type": MOTION, "motion": "text_object", "motion_args": { "inner": false, "object":"[" } }, { "keys": ["A", "Shift+BracketLeft"], "type": MOTION, "motion": "text_object", "motion_args": { "inner": false, "object":"{" } }, { "keys": ["A", "Shift+BracketRight"], "type": MOTION, "motion": "text_object", "motion_args": { "inner": false, "object":"{" } }, { "keys": ["A", "Shift+B"], "type": MOTION, "motion": "text_object", "motion_args": { "inner": false, "object":"{" } }, { "keys": ["A", "Apostrophe"], "type": MOTION, "motion": "text_object", "motion_args": { "inner": false, "object":"'" } }, { "keys": ["A", 'Shift+Apostrophe'], "type": MOTION, "motion": "text_object", "motion_args": { "inner": false, "object":'"' } }, { "keys": ["I", "Shift+9"], "type": MOTION, "motion": "text_object", "motion_args": { "inner": true, "object":"(" } }, { "keys": ["I", "Shift+0"], "type": MOTION, "motion": "text_object", "motion_args": { "inner": true, "object":"(" } }, { "keys": ["I", "B"], "type": MOTION, "motion": "text_object", "motion_args": { "inner": true, "object":"(" } }, { "keys": ["I", "BracketLeft"], "type": MOTION, "motion": "text_object", "motion_args": { "inner": true, "object":"[" } }, { "keys": ["I", "BracketRight"], "type": MOTION, "motion": "text_object", "motion_args": { "inner": true, "object":"[" } }, { "keys": ["I", "Shift+BracketLeft"], "type": MOTION, "motion": "text_object", "motion_args": { "inner": true, "object":"{" } }, { "keys": ["I", "Shift+BracketRight"], "type": MOTION, "motion": "text_object", "motion_args": { "inner": true, "object":"{" } }, { "keys": ["I", "Shift+B"], "type": MOTION, "motion": "text_object", "motion_args": { "inner": true, "object":"{" } }, { "keys": ["I", "Apostrophe"], "type": MOTION, "motion": "text_object", "motion_args": { "inner": true, "object":"'" } }, { "keys": ["I", 'Shift+Apostrophe'], "type": MOTION, "motion": "text_object", "motion_args": { "inner": true, "object":'"' } }, { "keys": ["I", "W"], "type": MOTION, "motion": "text_object", "motion_args": { "inner": true, "object":"w" } }, { "keys": ["Apostrophe", "{char}"], "type": MOTION, "motion": "go_to_bookmark", "motion_args": {} }, { "keys": ["D"], "type": OPERATOR, "operator": "delete" }, { "keys": ["Shift+D"], "type": OPERATOR_MOTION, "operator": "delete", "motion": "move_to_end_of_line", "motion_args": { "inclusive": true } }, { "keys": ["Y"], "type": OPERATOR, "operator": "yank", "operator_args": { "maintain_position": true } }, { "keys": ["Shift+Y"], "type": OPERATOR_MOTION, "operator": "yank", "motion": "move_to_end_of_line", "motion_args": { "inclusive": true }, "operator_args": { "maintain_position": true } }, { "keys": ["C"], "type": OPERATOR, "operator": "change" }, { "keys": ["Shift+C"], "type": OPERATOR_MOTION, "operator": "change", "motion": "move_to_end_of_line", "motion_args": { "inclusive": true } }, { "keys": ["X"], "type": OPERATOR_MOTION, "operator": "delete", "motion": "move_by_characters", "motion_args": { "forward": true, "one_line": true }, "context": Context.NORMAL }, { "keys": ["S"], "type": OPERATOR_MOTION, "operator": "change", "motion": "move_by_characters", "motion_args": { "forward": true }, "context": Context.NORMAL }, { "keys": ["X"], "type": OPERATOR, "operator": "delete", "context": Context.VISUAL }, { "keys": ["Shift+X"], "type": OPERATOR_MOTION, "operator": "delete", "motion": "move_by_characters", "motion_args": { "forward": false } }, { "keys": ["G", "U"], "type": OPERATOR, "operator": "change_case", "operator_args": { "lower": true }, "context": Context.VISUAL }, { "keys": ["G", "Shift+U"], "type": OPERATOR, "operator": "change_case", "operator_args": { "lower": false }, "context": Context.VISUAL }, { "keys": ["U"], "type": OPERATOR, "operator": "change_case", "operator_args": { "lower": true }, "context": Context.VISUAL }, { "keys": ["Shift+U"], "type": OPERATOR, "operator": "change_case", "operator_args": { "lower": false }, "context": Context.VISUAL }, { "keys": ["Shift+QuoteLeft"], "type": OPERATOR, "operator": "toggle_case", "operator_args": {}, "context": Context.VISUAL }, { "keys": ["Shift+QuoteLeft"], "type": OPERATOR_MOTION, "operator": "toggle_case", "motion": "move_by_characters", "motion_args": { "forward": true }, "context": Context.NORMAL }, { "keys": ["P"], "type": ACTION, "action": "paste", "action_args": { "after": true } }, { "keys": ["Shift+P"], "type": ACTION, "action": "paste", "action_args": { "after": false } }, { "keys": ["U"], "type": ACTION, "action": "undo", "action_args": {}, "context": Context.NORMAL }, { "keys": ["Ctrl+R"], "type": ACTION, "action": "redo", "action_args": {} }, { "keys": ["R", "{char}"], "type": ACTION, "action": "replace", "action_args": {} }, { "keys": ["Period"], "type": ACTION, "action": "repeat_last_edit", "action_args": {} }, { "keys": ["I"], "type": ACTION, "action": "enter_insert_mode", "action_args": { "insert_at": "inplace" }, "context": Context.NORMAL }, { "keys": ["Shift+I"], "type": ACTION, "action": "enter_insert_mode", "action_args": { "insert_at": "bol" } }, { "keys": ["A"], "type": ACTION, "action": "enter_insert_mode", "action_args": { "insert_at": "after" }, "context": Context.NORMAL }, { "keys": ["Shift+A"], "type": ACTION, "action": "enter_insert_mode", "action_args": { "insert_at": "eol" } }, { "keys": ["O"], "type": ACTION, "action": "enter_insert_mode", "action_args": { "insert_at": "new_line_below" } }, { "keys": ["Shift+O"], "type": ACTION, "action": "enter_insert_mode", "action_args": { "insert_at": "new_line_above" } }, { "keys": ["V"], "type": ACTION, "action": "enter_visual_mode", "action_args": { "line_wise": false } }, { "keys": ["Shift+V"], "type": ACTION, "action": "enter_visual_mode", "action_args": { "line_wise": true } }, { "keys": ["Slash"], "type": ACTION, "action": "search", "action_args": {} }, { "keys": ["Ctrl+O"], "type": ACTION, "action": "jump_list_walk", "action_args": { "forward": false } }, { "keys": ["Ctrl+I"], "type": ACTION, "action": "jump_list_walk", "action_args": { "forward": true } }, { "keys": ["Z", "A"], "type": ACTION, "action": "toggle_folding", }, { "keys": ["Z", "Shift+M"], "type": ACTION, "action": "fold_all", }, { "keys": ["Z", "Shift+R"], "type": ACTION, "action": "unfold_all", }, { "keys": ["Q", "{char}"], "type": ACTION, "action": "record_macro", "when_not": "is_recording" }, { "keys": ["Q"], "type": ACTION, "action": "stop_record_macro", "when": "is_recording" }, { "keys": ["Shift+2", "{char}"], "type": ACTION, "action": "play_macro", }, { "keys": ["Shift+Comma"], "type": ACTION, "action": "indent", "action_args": { "forward" = false} }, { "keys": ["Shift+Period"], "type": ACTION, "action": "indent", "action_args": { "forward" = true } }, { "keys": ["Shift+J"], "type": ACTION, "action": "join_lines", "action_args": {} }, { "keys": ["M", "{char}"], "type": ACTION, "action": "set_bookmark", "action_args": {} }, { "keys": ["Ctrl+BracketRight"], "type": ACTION, "action": "go_to_definition", "action_args": {} }, ] # The list of command keys we handle (other command keys will be handled by Godot) var command_keys_white_list : Dictionary = { "Escape": 1, "Enter": 1, # "Ctrl+F": 1, # Uncomment if you would like move-forward by page function instead of search on slash "Ctrl+B": 1, "Ctrl+U": 1, "Ctrl+D": 1, "Ctrl+O": 1, "Ctrl+I": 1, "Ctrl+R": 1, "Ctrl+BracketRight": 1, } var editor_interface : EditorInterface var the_ed := EditorAdaptor.new() # The current editor adaptor var the_vim := Vim.new() var the_dispatcher := CommandDispatcher.new(the_key_map) # The command dispatcher func _enter_tree() -> void: editor_interface = get_editor_interface() var script_editor = editor_interface.get_script_editor() script_editor.editor_script_changed.connect(on_script_changed) script_editor.script_close.connect(on_script_closed) on_script_changed(script_editor.get_current_script()) var settings = editor_interface.get_editor_settings() settings.settings_changed.connect(on_settings_changed) on_settings_changed() var find_bar = find_first_node_of_type(script_editor, 'FindReplaceBar') var find_bar_line_edit : LineEdit = find_first_node_of_type(find_bar, 'LineEdit') find_bar_line_edit.text_changed.connect(on_search_text_changed) func _input(event) -> void: var key = event as InputEventKey # Don't process when not a key action if key == null or !key.is_pressed() or not the_ed or not the_ed.has_focus(): return if key.get_keycode_with_modifiers() == KEY_NONE and key.unicode == CODE_MACRO_PLAY_END: the_vim.macro_manager.on_macro_finished(the_ed) get_viewport().set_input_as_handled() return # Check to not block some reserved keys (we only handle unicode keys and the white list) var key_code = key.as_text_keycode() if DEBUGGING: print("Key: %s Buffer: %s" % [key_code, the_vim.current.input_state.key_codes()]) # We only process keys in the white list or it is ASCII char or SHIFT+ASCII char if key.get_keycode_with_modifiers() & (~KEY_MASK_SHIFT) > 128 and key_code not in command_keys_white_list: return if the_dispatcher.dispatch(key, the_vim, the_ed): get_viewport().set_input_as_handled() func on_script_changed(s: Script) -> void: the_vim.set_current_session(s, the_ed) var script_editor = editor_interface.get_script_editor() var scrpit_editor_base := script_editor.get_current_editor() if scrpit_editor_base: var code_editor := scrpit_editor_base.get_base_editor() as CodeEdit the_ed.set_code_editor(code_editor) the_ed.set_block_caret(true) if not code_editor.is_connected("caret_changed", on_caret_changed): code_editor.caret_changed.connect(on_caret_changed) if not code_editor.is_connected("lines_edited_from", on_lines_edited_from): code_editor.lines_edited_from.connect(on_lines_edited_from) func on_script_closed(s: Script) -> void: the_vim.remove_session(s) func on_settings_changed() -> void: var settings := editor_interface.get_editor_settings() the_ed.notify_settings_changed(settings) func on_caret_changed()-> void: the_ed.set_block_caret(not the_vim.current.insert_mode) func on_lines_edited_from(from: int, to: int) -> void: the_vim.current.jump_list.on_lines_edited(from, to) the_vim.current.text_change_number += 1 the_vim.current.bookmark_manager.on_lines_edited(from, to) func on_search_text_changed(new_search_text: String) -> void: the_vim.search_buffer = new_search_text static func find_first_node_of_type(p: Node, type: String) -> Node: if p.get_class() == type: return p for c in p.get_children(): var t := find_first_node_of_type(c, type) if t: return t return null class Command: ### MOTIONS static func move_by_characters(cur: Position, args: Dictionary, ed: EditorAdaptor, vim: Vim) -> Position: var one_line = args.get('one_line', false) var col : int = cur.column + args.repeat * (1 if args.forward else -1) var line := cur.line if col > ed.last_column(line): if one_line: col = ed.last_column(line) + 1 else: line += 1 col = 0 elif col < 0: if one_line: col = 0 else: line -= 1 col = ed.last_column(line) return Position.new(line, col) static func move_by_scroll(cur: Position, args: Dictionary, ed: EditorAdaptor, vim: Vim) -> Position: var count = ed.get_visible_line_count(ed.first_visible_line(), ed.last_visible_line()) return Position.new(ed.next_unfolded_line(cur.line, count / 2, args.forward), cur.column) static func move_by_page(cur: Position, args: Dictionary, ed: EditorAdaptor, vim: Vim) -> Position: var count = ed.get_visible_line_count(ed.first_visible_line(), ed.last_visible_line()) return Position.new(ed.next_unfolded_line(cur.line, count, args.forward), cur.column) static func move_to_column(cur: Position, args: Dictionary, ed: EditorAdaptor, vim: Vim) -> Position: return Position.new(cur.line, args.repeat - 1) static func move_by_lines(cur: Position, args: Dictionary, ed: EditorAdaptor, vim: Vim) -> Position: # Depending what our last motion was, we may want to do different things. # If our last motion was moving vertically, we want to preserve the column from our # last horizontal move. If our last motion was going to the end of a line, # moving vertically we should go to the end of the line, etc. var col : int = cur.column match vim.current.last_motion: "move_by_lines", "move_by_scroll", "move_by_page", "move_to_end_of_line", "move_to_column": col = vim.current.last_h_pos _: vim.current.last_h_pos = col var line = ed.next_unfolded_line(cur.line, args.repeat, args.forward) if args.get("to_first_char", false): col = ed.find_first_non_white_space_character(line) else: col = min(col, ed.last_column(line)) return Position.new(line, col) static func move_to_first_non_white_space_character(cur: Position, args: Dictionary, ed: EditorAdaptor, vim: Vim) -> Position: var i := ed.find_first_non_white_space_character(ed.curr_line()) return Position.new(cur.line, i) static func move_to_start_of_line(cur: Position, args: Dictionary, ed: EditorAdaptor, vim: Vim) -> Position: return Position.new(cur.line, 0) static func move_to_end_of_line(cur: Position, args: Dictionary, ed: EditorAdaptor, vim: Vim) -> Position: var line = cur.line if args.repeat > 1: line = ed.next_unfolded_line(line, args.repeat - 1) vim.current.last_h_pos = INF_COL return Position.new(line, ed.last_column(line)) static func move_to_top_line(cur: Position, args: Dictionary, ed: EditorAdaptor, vim: Vim) -> Position: return Position.new(ed.first_visible_line(), cur.column) static func move_to_bottom_line(cur: Position, args: Dictionary, ed: EditorAdaptor, vim: Vim) -> Position: return Position.new(ed.last_visible_line(), cur.column) static func move_to_middle_line(cur: Position, args: Dictionary, ed: EditorAdaptor, vim: Vim) -> Position: var first := ed.first_visible_line() var count = ed.get_visible_line_count(first, ed.last_visible_line()) return Position.new(ed.next_unfolded_line(first, count / 2), cur.column) static func move_to_line_or_edge_of_document(cur: Position, args: Dictionary, ed: EditorAdaptor, vim: Vim) -> Position: var line = ed.last_line() if args.forward else ed.first_line() if args.repeat_is_explicit: line = args.repeat + ed.first_line() - 1 return Position.new(line, ed.find_first_non_white_space_character(line)) static func move_by_words(cur: Position, args: Dictionary, ed: EditorAdaptor, vim: Vim) -> Position: var start_line := cur.line var start_col := cur.column var start_pos := cur.duplicate() # If we are beyond line end, move it to line end if start_col > 0 and start_col == ed.last_column(start_line) + 1: cur = Position.new(start_line, start_col-1) var forward : bool = args.forward var word_end : bool = args.word_end var big_word : bool = args.big_word var repeat : int = args.repeat var empty_line_is_word := not (forward and word_end) # For 'e', empty lines are not considered words var one_line := not vim.current.input_state.operator.is_empty() # if there is an operator pending, let it not beyond the line end each time if (forward and !word_end) or (not forward and word_end): # w or ge repeat += 1 var words : Array[TextRange] = [] for i in range(repeat): var word = _find_word(cur, ed, forward, big_word, empty_line_is_word, one_line) if word != null: words.append(word) cur = Position.new(word.from.line, word.to.column-1 if forward else word.from.column) else: # eof words.append(TextRange.new(ed.last_pos(), ed.last_pos()) if forward else TextRange.zero) break var short_circuit : bool = len(words) != repeat var first_word := words[0] var last_word : TextRange = words.pop_back() if forward and not word_end: # w if vim.current.input_state.operator == "change": # cw need special treatment to not cover whitespaces if not short_circuit: last_word = words.pop_back() return last_word.to if not short_circuit and not start_pos.equals(first_word.from): last_word = words.pop_back() # We did not start in the middle of a word. Discard the extra word at the end. return last_word.from elif forward and word_end: # e return last_word.to.left() elif not forward and word_end: # ge if not short_circuit and not start_pos.equals(first_word.to.left()): last_word = words.pop_back() # We did not start in the middle of a word. Discard the extra word at the end. return last_word.to.left() else: # b return last_word.from static func move_to_matched_symbol(cur: Position, args: Dictionary, ed: EditorAdaptor, vim: Vim) -> Position: # Get the symbol to match var symbol := ed.find_forward(cur.line, cur.column, func(c): return c.char in "()[]{}", true) if symbol == null: # No symbol found in this line after or under caret return null var counter_part : String = SYMBOLS[symbol.char] # Two attemps to find the symbol pair: from line start or doc start for from in [Position.new(symbol.line, 0), Position.new(0, 0)]: var parser = GDScriptParser.new(ed, from) if not parser.parse_until(symbol): continue if symbol.char in ")]}": parser.stack.reverse() for p in parser.stack: if p.char == counter_part: return p continue else: parser.parse_one_char() return parser.find_matching() return null static func move_to_next_char(cur: Position, args: Dictionary, ed: EditorAdaptor, vim: Vim) -> Position: vim.last_char_search = args var forward : bool = args.forward var stop_before : bool = args.get("stop_before", false) var to_find = args.selected_character var repeat : int = args.repeat var old_pos := cur.duplicate() for ch in ed.chars(cur.line, cur.column + (1 if forward else -1), forward, true): if ch.char == to_find: repeat -= 1 if repeat == 0: return old_pos if stop_before else Position.new(ch.line, ch.column) old_pos = Position.new(ch.line, ch.column) return null static func repeat_last_char_search(cur: Position, args: Dictionary, ed: EditorAdaptor, vim: Vim) -> Position: var last_char_search := vim.last_char_search if last_char_search.is_empty(): return null args.forward = last_char_search.forward args.selected_character = last_char_search.selected_character args.stop_before = last_char_search.get("stop_before", false) args.inclusive = last_char_search.get("inclusive", false) return move_to_next_char(cur, args, ed, vim) static func expand_to_line(cur: Position, args: Dictionary, ed: EditorAdaptor, vim: Vim) -> Position: return Position.new(cur.line + args.repeat - 1, INF_COL) static func find_word_under_caret(cur: Position, args: Dictionary, ed: EditorAdaptor, vim: Vim) -> Position: var forward : bool = args.forward var range := ed.get_word_at_pos(cur.line, cur.column) var text := ed.range_text(range) var pos := ed.search(text, cur.line, cur.column + (1 if forward else -1), false, true, forward) vim.last_search_command = "*" if forward else "#" vim.search_buffer = text return pos static func find_again(cur: Position, args: Dictionary, ed: EditorAdaptor, vim: Vim) -> Position: var forward : bool = args.forward forward = forward == (vim.last_search_command != "#") var case_sensitive := vim.last_search_command in "*#" var whole_word := vim.last_search_command in "*#" cur = cur.next(ed) if forward else cur.prev(ed) return ed.search(vim.search_buffer, cur.line, cur.column, case_sensitive, whole_word, forward) static func text_object(cur: Position, args: Dictionary, ed: EditorAdaptor, vim: Vim) -> Variant: var inner : bool = args.inner var obj : String = args.object if obj == "w" and inner: return ed.get_word_at_pos(cur.line, cur.column) if obj in "([{\"'": var counter_part : String = SYMBOLS[obj] for from in [Position.new(cur.line, 0), Position.new(0, 0)]: # Two attemps: from line beginning doc beginning var parser = GDScriptParser.new(ed, from) if not parser.parse_until(cur): continue var range = TextRange.zero if parser.stack_top_char == obj: range.from = parser.stack.back() range.to = parser.find_matching() elif ed.char_at(cur.line, cur.column) == obj: parser.parse_one_char() range.from = parser.pos range.to = parser.find_matching() else: continue if range.from == null or range.to == null: continue if inner: range.from = range.from.next(ed) else: range.to = range.to.next(ed) return range return null static func go_to_bookmark(cur: Position, args: Dictionary, ed: EditorAdaptor, vim: Vim) -> Position: var name = args.selected_character var line := vim.current.bookmark_manager.get_bookmark(name) if line < 0: return null return Position.new(line, 0) ### OPERATORS static func delete(args: Dictionary, ed: EditorAdaptor, vim: Vim) -> void: var text := ed.selected_text() var line_wise = args.get("line_wise", false) vim.register.set_text(text, line_wise) ed.begin_complex_operation() ed.delete_selection() # For linewise delete, we want to delete one more line if line_wise: ed.select(ed.curr_line(), -1, ed.curr_line()+1, -1) ed.delete_selection() ed.end_complex_operation() var line := ed.curr_line() var col := ed.curr_column() if col > ed.last_column(line): # If after deletion we are beyond the end, move left ed.set_curr_column(ed.last_column(line)) static func yank(args: Dictionary, ed: EditorAdaptor, vim: Vim) -> void: var text := ed.selected_text() ed.deselect() vim.register.set_text(text, args.get("line_wise", false)) static func change(args: Dictionary, ed: EditorAdaptor, vim: Vim) -> void: var text := ed.selected_text() vim.register.set_text(text, args.get("line_wise", false)) vim.current.enter_insert_mode(); ed.delete_selection() static func change_case(args: Dictionary, ed: EditorAdaptor, vim: Vim) -> void: var lower_case : bool = args.get("lower", false) var text := ed.selected_text() ed.replace_selection(text.to_lower() if lower_case else text.to_upper()) static func toggle_case(args: Dictionary, ed: EditorAdaptor, vim: Vim) -> void: var text := ed.selected_text() var s := PackedStringArray() for c in text: s.append(c.to_lower() if c == c.to_upper() else c.to_upper()) ed.replace_selection(''.join(s)) ### ACTIONS static func paste(args: Dictionary, ed: EditorAdaptor, vim: Vim) -> void: var after : bool = args.after var line_wise := vim.register.line_wise var clipboard_text := vim.register.text var text : String = "" for i in range(args.repeat): text += clipboard_text var line := ed.curr_line() var col := ed.curr_column() ed.begin_complex_operation() if vim.current.visual_mode: ed.delete_selection() else: if line_wise: if after: text = "\n" + text col = len(ed.line_text(line)) else: text = text + "\n" col = 0 else: col += 1 if after else 0 ed.set_curr_column(col) ed.insert_text(text) ed.end_complex_operation() static func undo(args: Dictionary, ed: EditorAdaptor, vim: Vim) -> void: for i in range(args.repeat): ed.undo() ed.deselect() static func redo(args: Dictionary, ed: EditorAdaptor, vim: Vim) -> void: for i in range(args.repeat): ed.redo() ed.deselect() static func replace(args: Dictionary, ed: EditorAdaptor, vim: Vim) -> void: var to_replace = args.selected_character var line := ed.curr_line() var col := ed.curr_column() ed.select(line, col, line, col) ed.replace_selection(to_replace) static func enter_insert_mode(args: Dictionary, ed: EditorAdaptor, vim: Vim) -> void: var insert_at : String = args.insert_at var line = ed.curr_line() var col = ed.curr_column() vim.current.enter_insert_mode() match insert_at: "inplace": pass "after": ed.set_curr_column(col + 1) "bol": ed.set_curr_column(ed.find_first_non_white_space_character(line)) "eol": ed.set_curr_column(INF_COL) "new_line_below": ed.set_curr_column(INF_COL) ed.simulate_press(KEY_ENTER) "new_line_above": ed.set_curr_column(0) if line == ed.first_line(): ed.insert_text("\n") ed.jump_to(0, 0) else: ed.jump_to(line - 1, INF_COL) ed.simulate_press(KEY_ENTER) static func enter_visual_mode(args: Dictionary, ed: EditorAdaptor, vim: Vim) -> void: var line_wise : bool = args.get('line_wise', false) vim.current.enter_visual_mode(line_wise) static func search(args: Dictionary, ed: EditorAdaptor, vim: Vim) -> void: if OS.get_name() == "macOS": ed.simulate_press(KEY_F, 0, false, false, false, true) else: ed.simulate_press(KEY_F, 0, true, false, false, false) vim.last_search_command = "/" static func jump_list_walk(args: Dictionary, ed: EditorAdaptor, vim: Vim) -> void: var offset : int = args.repeat * (1 if args.forward else -1) var pos : Position = vim.current.jump_list.move(offset) if pos != null: if not args.forward: vim.current.jump_list.set_next(ed.curr_position()) ed.jump_to(pos.line, pos.column) static func toggle_folding(args: Dictionary, ed: EditorAdaptor, vim: Vim) -> void: ed.toggle_folding(ed.curr_line()) static func fold_all(args: Dictionary, ed: EditorAdaptor, vim: Vim) -> void: ed.fold_all() static func unfold_all(args: Dictionary, ed: EditorAdaptor, vim: Vim) -> void: ed.unfold_all() static func repeat_last_edit(args: Dictionary, ed: EditorAdaptor, vim: Vim) -> void: var repeat : int = args.repeat vim.macro_manager.play_macro(repeat, ".", ed) static func record_macro(args: Dictionary, ed: EditorAdaptor, vim: Vim) -> void: var name = args.selected_character if name in ALPHANUMERIC: vim.macro_manager.start_record_macro(name) static func stop_record_macro(args: Dictionary, ed: EditorAdaptor, vim: Vim) -> void: vim.macro_manager.stop_record_macro() static func play_macro(args: Dictionary, ed: EditorAdaptor, vim: Vim) -> void: var name = args.selected_character var repeat : int = args.repeat if name in ALPHANUMERIC: vim.macro_manager.play_macro(repeat, name, ed) static func is_recording(ed: EditorAdaptor, vim: Vim) -> bool: return vim.macro_manager.is_recording() static func indent(args: Dictionary, ed: EditorAdaptor, vim: Vim) -> void: var repeat : int = args.repeat var forward : bool = args.get("forward", false) var range = ed.selection() if not range.is_single_line() and range.to.column == 0: # Don't select the last empty line ed.select(range.from.line, range.from.column, range.to.line-1, INF_COL) ed.begin_complex_operation() for i in range(repeat): if forward: ed.indent() else: ed.unindent() ed.end_complex_operation() static func join_lines(args: Dictionary, ed: EditorAdaptor, vim: Vim) -> void: if vim.current.normal_mode: var line := ed.curr_line() ed.select(line, 0, line + args.repeat, INF_COL) var range := ed.selection() ed.select(range.from.line, 0, range.to.line, INF_COL) var s := PackedStringArray() s.append(ed.line_text(range.from.line)) for line in range(range.from.line + 1, range.to.line + 1): s.append(ed.line_text(line).lstrip(' \t\n')) ed.replace_selection(' '.join(s)) static func set_bookmark(args: Dictionary, ed: EditorAdaptor, vim: Vim) -> void: var name = args.selected_character if name in LOWER_ALPHA: vim.current.bookmark_manager.set_bookmark(name, ed.curr_line()) static func go_to_definition(args: Dictionary, ed: EditorAdaptor, vim: Vim) -> void: var pos_before := ed.curr_position() ed.go_to_definition() await ed.code_editor.get_tree().process_frame var pos_after := ed.curr_position() if not pos_before.equals(pos_after): vim.current.jump_list.add(pos_before, pos_after) ### HELPER FUNCTIONS ## Returns the boundaries of the next word. If the cursor in the middle of the word, then returns the boundaries of the current word, starting at the cursor. ## If the cursor is at the start/end of a word, and we are going forward/backward, respectively, find the boundaries of the next word. static func _find_word(cur: Position, ed: EditorAdaptor, forward: bool, big_word: bool, empty_line_is_word: bool, one_line: bool) -> TextRange: var char_tests := [ func(c): return c in ALPHANUMERIC or c in BREAKERS ] if big_word else [ func(c): return c in ALPHANUMERIC, func(c): return c in BREAKERS ] for p in ed.chars(cur.line, cur.column, forward): if one_line and p.char == '\n': # If we only allow search in one line and we met the line end return TextRange.from_num3(p.line, p.column, INF_COL) if p.line != cur.line and empty_line_is_word and p.line_text.strip_edges() == '': return TextRange.from_num3(p.line, 0, 0) for char_test in char_tests: if char_test.call(p.char): var word_start := p.column var word_end := word_start for q in ed.chars(p.line, p.column, forward, true): # Advance to end of word. if not char_test.call(q.char): break word_end = q.column if p.line == cur.line and word_start == cur.column and word_end == word_start: continue # We started at the end of a word. Find the next one. else: return TextRange.from_num3(p.line, min(word_start, word_end), max(word_start + 1, word_end + 1)) return null class Position: var line: int var column: int static var zero :Position: get: return Position.new(0, 0) func _init(l: int, c: int): line = l column = c func _to_string() -> String: return "(%s, %s)" % [line, column] func equals(other: Position) -> bool: return line == other.line and column == other.column func compares_to(other: Position) -> int: if line < other.line: return -1 if line > other.line: return 1 if column < other.column: return -1 if column > other.column: return 1 return 0 func duplicate() -> Position: return Position.new(line, column) func up() -> Position: return Position.new(line-1, column) func down() -> Position: return Position.new(line+1, column) func left() -> Position: return Position.new(line, column-1) func right() -> Position: return Position.new(line, column+1) func next(ed: EditorAdaptor) -> Position: return ed.offset_pos(self, 1) func prev(ed: EditorAdaptor) -> Position: return ed.offset_pos(self, -1) class TextRange: var from: Position var to: Position static var zero : TextRange: get: return TextRange.new(Position.zero, Position.zero) static func from_num4(from_line: int, from_column: int, to_line: int, to_column: int): return TextRange.new(Position.new(from_line, from_column), Position.new(to_line, to_column)) static func from_num3(line: int, from_column: int, to_column: int): return from_num4(line, from_column, line, to_column) func _init(f: Position, t: Position): from = f to = t func _to_string() -> String: return "[%s - %s]" % [from, to] func is_single_line() -> bool: return from.line == to.line func is_empty() -> bool: return from.line == to.line and from.column == to.column class CharPos extends Position: var line_text : String var char: String: get: return line_text[column] if column < len(line_text) else '\n' func _init(line_text: String, line: int, col: int): super(line, col) self.line_text = line_text class JumpList: var buffer: Array[Position] var pointer: int = 0 func _init(capacity: int = 20): buffer = [] buffer.resize(capacity) func add(old_pos: Position, new_pos: Position) -> void: var current : Position = buffer[pointer] if current == null or not current.equals(old_pos): pointer = (pointer + 1) % len(buffer) buffer[pointer] = old_pos pointer = (pointer + 1) % len(buffer) buffer[pointer] = new_pos func set_next(pos: Position) -> void: buffer[(pointer + 1) % len(buffer)] = pos # This overrides next forward position (TODO: an insert might be better) func move(offset: int) -> Position: var t := (pointer + offset) % len(buffer) var r : Position = buffer[t] if r != null: pointer = t return r func on_lines_edited(from: int, to: int) -> void: for pos in buffer: if pos != null and pos.line > from: # Unfortunately we don't know which column changed pos.line += to - from class InputState: var prefix_repeat: String var motion_repeat: String var operator: String var operator_args: Dictionary var buffer: Array[InputEventKey] = [] func push_key(key: InputEventKey) -> void: buffer.append(key) func push_repeat_digit(d: String) -> void: if operator.is_empty(): prefix_repeat += d else: motion_repeat += d func get_repeat() -> int: var repeat : int = 0 if prefix_repeat: repeat = max(repeat, 1) * int(prefix_repeat) if motion_repeat: repeat = max(repeat, 1) * int(motion_repeat) return repeat func key_codes() -> Array[String]: var r : Array[String] = [] for k in buffer: r.append(k.as_text_keycode()) return r func clear() -> void: prefix_repeat = "" motion_repeat = "" operator = "" buffer.clear() class GDScriptParser: const open_symbol := { "(": ")", "[": "]", "{": "}", "'": "'", '"': '"' } const close_symbol := { ")": "(", "]": "[", "}": "{", } var stack : Array[CharPos] var in_comment := false var escape_count := 0 var valid: bool = true var eof : bool = false var pos: Position var stack_top_char : String: get: return "" if stack.is_empty() else stack.back().char var _it: CharIterator var _ed : EditorAdaptor func _init(ed: EditorAdaptor, from: Position): _ed = ed _it = ed.chars(from.line, from.column) if not _it._iter_init(null): eof = true func parse_until(to: Position) -> bool: while valid and not eof: parse_one_char() if _it.line == to.line and _it.column == to.column: break return valid and not eof func find_matching() -> Position: var depth := len(stack) while valid and not eof: parse_one_char() if len(stack) < depth: return pos return null func parse_one_char() -> String: # ChatGPT got credit here if eof or not valid: return "" var p := _it._iter_get(null) pos = p if not _it._iter_next(null): eof = true var char := p.char var top: String = '' if stack.is_empty() else stack.back().char if top in "'\"": # in string if char == top and escape_count % 2 == 0: stack.pop_back() escape_count = 0 return char escape_count = escape_count + 1 if char == "\\" else 0 elif in_comment: if char == "\n": in_comment = false elif char == "#": in_comment = true elif char in open_symbol: stack.append(p) return char elif char in close_symbol: if top == close_symbol[char]: stack.pop_back() return char else: valid = false return "" class Register: var line_wise : bool = false var text : String: get: return DisplayServer.clipboard_get() func set_text(value: String, line_wise: bool) -> void: self.line_wise = line_wise DisplayServer.clipboard_set(value) class BookmarkManager: var bookmarks : Dictionary func on_lines_edited(from: int, to: int) -> void: for b in bookmarks: var line : int = bookmarks[b] if line > from: bookmarks[b] += to - from func set_bookmark(name: String, line: int) -> void: bookmarks[name] = line func get_bookmark(name: String) -> int: return bookmarks.get(name, -1) class CommandMatchResult: var full: Array[Dictionary] = [] var partial: Array[Dictionary] = [] class VimSession: var ed : EditorAdaptor ## Mode insert_mode | visual_mode | visual_line ## Insert true | false | false ## Normal false | false | false ## Visual false | true | false ## Visual Line false | true | true var insert_mode : bool = false var visual_mode : bool = false var visual_line : bool = false var normal_mode: bool: get: return not insert_mode and not visual_mode ## Pending input var input_state := InputState.new() ## The last motion occurred var last_motion : String ## When using jk for navigation, if you move from a longer line to a shorter line, the cursor may clip to the end of the shorter line. ## If j is pressed again and cursor goes to the next line, the cursor should go back to its horizontal position on the longer ## line if it can. This is to keep track of the horizontal position. var last_h_pos : int = 0 ## How many times text are changed var text_change_number : int ## List of positions for C-I and C-O var jump_list := JumpList.new() ## The bookmark manager of the session var bookmark_manager := BookmarkManager.new() ## The start position of visual mode var visual_start_pos := Position.zero func enter_normal_mode() -> void: if insert_mode: ed.end_complex_operation() # Wrap up the undo operation when we get out of insert mode insert_mode = false visual_mode = false visual_line = false ed.set_block_caret(true) func enter_insert_mode() -> void: insert_mode = true visual_mode = false visual_line = false ed.set_block_caret(false) ed.begin_complex_operation() func enter_visual_mode(line_wise: bool) -> void: insert_mode = false visual_mode = true visual_line = line_wise ed.set_block_caret(true) visual_start_pos = ed.curr_position() if line_wise: ed.select(visual_start_pos.line, 0, visual_start_pos.line, INF_COL) else: ed.select_by_pos2(visual_start_pos, visual_start_pos) class Macro: var keys : Array[InputEventKey] = [] var enabled := false func _to_string() -> String: var s := PackedStringArray() for key in keys: s.append(key.as_text_keycode()) return ",".join(s) func play(ed: EditorAdaptor) -> void: for key in keys: ed.simulate_press_key(key) ed.simulate_press(KEY_ESCAPE) class MacroManager: var vim : Vim var macros : Dictionary = {} var recording_name : String var playing_names : Array[String] = [] var command_buffer: Array[InputEventKey] func _init(vim: Vim): self.vim = vim func start_record_macro(name: String): print('Recording macro "%s"...' % name ) macros[name] = Macro.new() recording_name = name func stop_record_macro() -> void: print('Stop recording macro "%s"' % recording_name) macros[recording_name].enabled = true recording_name = "" func is_recording() -> bool: return recording_name != "" func play_macro(n: int, name: String, ed: EditorAdaptor) -> void: var macro : Macro = macros.get(name, null) if (macro == null or not macro.enabled): return if name in playing_names: return # to avoid recursion playing_names.append(name) if len(playing_names) == 1: ed.begin_complex_operation() if DEBUGGING: print("Playing macro %s: %s" % [name, macro]) for i in range(n): macro.play(ed) ed.simulate_press(KEY_NONE, CODE_MACRO_PLAY_END) # This special marks the end of macro play func on_macro_finished(ed: EditorAdaptor): var name : String = playing_names.pop_back() if playing_names.is_empty(): ed.end_complex_operation() func push_key(key: InputEventKey) -> void: command_buffer.append(key) if recording_name: macros[recording_name].keys.append(key) func on_command_processed(command: Dictionary, is_edit: bool) -> void: if is_edit and command.get('action', '') != "repeat_last_edit": var macro := Macro.new() macro.keys = command_buffer.duplicate() macro.enabled = true macros["."] = macro command_buffer.clear() ## Global VIM state; has multiple sessions class Vim: var sessions : Dictionary var current: VimSession var register: Register = Register.new() var last_char_search: Dictionary = {} # { selected_character, stop_before, forward, inclusive } var last_search_command: String var search_buffer: String var macro_manager := MacroManager.new(self) func set_current_session(s: Script, ed: EditorAdaptor): var session : VimSession = sessions.get(s) if not session: session = VimSession.new() session.ed = ed sessions[s] = session current = session func remove_session(s: Script): sessions.erase(s) class CharIterator: var ed : EditorAdaptor var line : int var column : int var forward : bool var one_line : bool var line_text : String func _init(ed: EditorAdaptor, line: int, col: int, forward: bool, one_line: bool): self.ed = ed self.line = line self.column = col self.forward = forward self.one_line = one_line func _ensure_column_valid() -> bool: if column < 0 or column > len(line_text): line += 1 if forward else -1 if one_line or line < 0 or line > ed.last_line(): return false line_text = ed.line_text(line) column = 0 if forward else len(line_text) return true func _iter_init(arg) -> bool: if line < 0 or line > ed.last_line(): return false line_text = ed.line_text(line) return _ensure_column_valid() func _iter_next(arg) -> bool: column += 1 if forward else -1 return _ensure_column_valid() func _iter_get(arg) -> CharPos: return CharPos.new(line_text, line, column) class EditorAdaptor: var code_editor: CodeEdit var tab_width : int = 4 var complex_ops : int = 0 func set_code_editor(new_editor: CodeEdit) -> void: self.code_editor = new_editor func notify_settings_changed(settings: EditorSettings) -> void: tab_width = settings.get_setting("text_editor/behavior/indent/size") as int func curr_position() -> Position: return Position.new(code_editor.get_caret_line(), code_editor.get_caret_column()) func curr_line() -> int: return code_editor.get_caret_line() func curr_column() -> int: return code_editor.get_caret_column() func set_curr_column(col: int) -> void: code_editor.set_caret_column(col) func jump_to(line: int, col: int) -> void: code_editor.unfold_line(line) if line < first_visible_line(): code_editor.set_line_as_first_visible(max(0, line-8)) elif line > last_visible_line(): code_editor.set_line_as_last_visible(min(last_line(), line+8)) code_editor.set_caret_line(line) code_editor.set_caret_column(col) func first_line() -> int: return 0 func last_line() -> int : return code_editor.get_line_count() - 1 func first_visible_line() -> int: return code_editor.get_first_visible_line() func last_visible_line() -> int: return code_editor.get_last_full_visible_line() func get_visible_line_count(from_line: int, to_line: int) -> int: return code_editor.get_visible_line_count_in_range(from_line, to_line) func next_unfolded_line(line: int, offset: int = 1, forward: bool = true) -> int: var step : int = 1 if forward else -1 if line + step > last_line() or line + step < first_line(): return line var count := code_editor.get_next_visible_line_offset_from(line + step, offset * step) return line + count * (1 if forward else -1) func last_column(line: int = -1) -> int: if line == -1: line = curr_line() return len(line_text(line)) - 1 func last_pos() -> Position: var line = last_line() return Position.new(line, last_column(line)) func line_text(line: int) -> String: return code_editor.get_line(line) func range_text(range: TextRange) -> String: var s := PackedStringArray() for p in chars(range.from.line, range.from.column): if p.equals(range.to): break s.append(p.char) return "".join(s) func char_at(line: int, col: int) -> String: var s := line_text(line) return s[col] if col >= 0 and col < len(s) else '' func go_to_definition() -> void: var symbol := code_editor.get_word_under_caret() code_editor.symbol_lookup.emit(symbol, curr_line(), curr_column()) func set_block_caret(block: bool) -> void: if block: if curr_column() == last_column() + 1: code_editor.caret_type = TextEdit.CARET_TYPE_LINE code_editor.add_theme_constant_override("caret_width", 8) else: code_editor.caret_type = TextEdit.CARET_TYPE_BLOCK code_editor.add_theme_constant_override("caret_width", 1) else: code_editor.add_theme_constant_override("caret_width", 1) code_editor.caret_type = TextEdit.CARET_TYPE_LINE func deselect() -> void: code_editor.deselect() func select_by_pos2(from: Position, to: Position) -> void: select(from.line, from.column, to.line, to.column) func select(from_line: int, from_col: int, to_line: int, to_col: int) -> void: # If we try to select backward pass the first line, select the first char if to_line < 0: to_line = 0 to_col = 0 # If we try to select pass the last line, select till the last char elif to_line > last_line(): to_line = last_line() to_col = INF_COL # Our select() is inclusive, e.g. ed.select(0, 0, 0, 0) selects the first character; # while CodeEdit's select() is exlusvie on the right hand side, e.g. code_editor.select(0, 0, 0, 1) selects the first character. # We do the translation here: if from_line < to_line or (from_line == to_line and from_col <= to_col): # Selecting forward to_col += 1 else: from_col += 1 if DEBUGGING: print(" Selecting from (%d,%d) to (%d,%d)" % [from_line, from_col, to_line, to_col]) code_editor.select(from_line, from_col, to_line, to_col) func delete_selection() -> void: code_editor.delete_selection() func selected_text() -> String: return code_editor.get_selected_text() func selection() -> TextRange: var from := Position.new(code_editor.get_selection_from_line(), code_editor.get_selection_from_column()) var to := Position.new(code_editor.get_selection_to_line(), code_editor.get_selection_to_column()) return TextRange.new(from, to) func replace_selection(text: String) -> void: var col := curr_column() begin_complex_operation() delete_selection() insert_text(text) end_complex_operation() set_curr_column(col) func toggle_folding(line_or_above: int) -> void: if code_editor.is_line_folded(line_or_above): code_editor.unfold_line(line_or_above) else: while line_or_above >= 0: if code_editor.can_fold_line(line_or_above): code_editor.fold_line(line_or_above) break line_or_above -= 1 func fold_all() -> void: code_editor.fold_all_lines() func unfold_all() -> void: code_editor.unfold_all_lines() func insert_text(text: String) -> void: code_editor.insert_text_at_caret(text) func offset_pos(pos: Position, offset: int) -> Position: var count : int = abs(offset) + 1 for p in chars(pos.line, pos.column, offset > 0): count -= 1 if count == 0: return p return null func undo() -> void: code_editor.undo() func redo() -> void: code_editor.redo() func indent() -> void: code_editor.indent_lines() func unindent() -> void: code_editor.unindent_lines() func simulate_press_key(key: InputEventKey): for pressed in [true, false]: var key2 := key.duplicate() key2.pressed = pressed Input.parse_input_event(key2) func simulate_press(keycode: Key, unicode: int=0, ctrl=false, alt=false, shift=false, meta=false) -> void: var k = InputEventKey.new() if ctrl: k.ctrl_pressed = true if shift: k.shift_pressed = true if alt: k.alt_pressed = true if meta: k.meta_pressed = true k.keycode = keycode k.key_label = keycode k.unicode = unicode simulate_press_key(k) func begin_complex_operation() -> void: complex_ops += 1 if complex_ops == 1: if DEBUGGING: print("Complex operation begins") code_editor.begin_complex_operation() func end_complex_operation() -> void: complex_ops -= 1 if complex_ops == 0: if DEBUGGING: print("Complex operation ends") code_editor.end_complex_operation() ## Return the index of the first non whtie space character in string func find_first_non_white_space_character(line: int) -> int: var s := line_text(line) return len(s) - len(s.lstrip(" \t\r\n")) ## Return the next (or previous) char from current position and update current position according. Return "" if not more char available func chars(line: int, col: int, forward: bool = true, one_line: bool = false) -> CharIterator: return CharIterator.new(self, line, col, forward, one_line) func find_forward(line: int, col: int, condition: Callable, one_line: bool = false) -> CharPos: for p in chars(line, col, true, one_line): if condition.call(p): return p return null func find_backforward(line: int, col: int, condition: Callable, one_line: bool = false) -> CharPos: for p in chars(line, col, false, one_line): if condition.call(p): return p return null func get_word_at_pos(line: int, col: int) -> TextRange: var end := find_forward(line, col, func(p): return p.char not in ALPHANUMERIC, true); var start := find_backforward(line, col, func(p): return p.char not in ALPHANUMERIC, true); return TextRange.new(start.right(), end) func search(text: String, line: int, col: int, match_case: bool, whole_word: bool, forward: bool) -> Position: var flags : int = 0 if match_case: flags |= TextEdit.SEARCH_MATCH_CASE if whole_word: flags |= TextEdit.SEARCH_WHOLE_WORDS if not forward: flags |= TextEdit.SEARCH_BACKWARDS var result = code_editor.search(text, flags, line, col) if result.x < 0 or result. y < 0: return null code_editor.set_search_text(text) return Position.new(result.y, result.x) func has_focus() -> bool: return weakref(code_editor).get_ref() and code_editor.has_focus() class CommandDispatcher: var key_map : Array[Dictionary] func _init(km: Array[Dictionary]): self.key_map = km func dispatch(key: InputEventKey, vim: Vim, ed: EditorAdaptor) -> bool: var key_code := key.as_text_keycode() var input_state := vim.current.input_state vim.macro_manager.push_key(key) if key_code == "Escape": input_state.clear() vim.macro_manager.on_command_processed({}, vim.current.insert_mode) # From insert mode to normal mode, this marks the end of an edit command vim.current.enter_normal_mode() return false # Let godot get the Esc as well to dispose code completion pops, etc if vim.current.insert_mode: # We are in insert mode return false # Let Godot CodeEdit handle it if key_code not in ["Shift", "Ctrl", "Alt", "Escape"]: # Don't add these to input buffer # Handle digits if key_code.is_valid_int() and input_state.buffer.is_empty(): input_state.push_repeat_digit(key_code) if input_state.get_repeat() > 0: # No more handding if it is only repeat digit return true # Save key to buffer input_state.push_key(key) # Match the command var context = Context.VISUAL if vim.current.visual_mode else Context.NORMAL var result = match_commands(context, vim.current.input_state, ed, vim) if not result.full.is_empty(): var command = result.full[0].duplicate(true) var change_num := vim.current.text_change_number if process_command(command, ed, vim): input_state.clear() if vim.current.normal_mode: vim.macro_manager.on_command_processed(command, vim.current.text_change_number > change_num) # Notify macro manager about the finished command elif result.partial.is_empty(): input_state.clear() return true # We handled the input func match_commands(context: Context, input_state: InputState, ed: EditorAdaptor, vim: Vim) -> CommandMatchResult: # Partial matches are not applied. They inform the key handler # that the current key sequence is a subsequence of a valid key # sequence, so that the key buffer is not cleared. var result := CommandMatchResult.new() var pressed := input_state.key_codes() for command in key_map: if not is_command_available(command, context, ed, vim): continue var mapped : Array = command.keys if mapped[-1] == "{char}": if pressed.slice(0, -1) == mapped.slice(0, -1) and len(pressed) == len(mapped): result.full.append(command) elif mapped.slice(0, len(pressed)-1) == pressed.slice(0, -1): result.partial.append(command) else: continue else: if pressed == mapped: result.full.append(command) elif mapped.slice(0, len(pressed)) == pressed: result.partial.append(command) else: continue return result func is_command_available(command: Dictionary, context: Context, ed: EditorAdaptor, vim: Vim) -> bool: if command.get("context") not in [null, context]: return false var when : String = command.get("when", '') if when and not Callable(Command, when).call(ed, vim): return false var when_not: String = command.get("when_not", '') if when_not and Callable(Command, when_not).call(ed, vim): return false return true func process_command(command: Dictionary, ed: EditorAdaptor, vim: Vim) -> bool: var vim_session := vim.current var input_state := vim_session.input_state # We respecte selection start position if we are in visual mod var start := vim_session.visual_start_pos if vim_session.visual_mode else Position.new(ed.curr_line(), ed.curr_column()) # If there is an operator pending, then we do need a motion or operator (for linewise operation) if not input_state.operator.is_empty() and (command.type != MOTION and command.type != OPERATOR): return false if command.type == ACTION: var action_args = command.get("action_args", {}) if command.keys[-1] == "{char}": action_args.selected_character = char(input_state.buffer.back().unicode) process_action(command.action, action_args, ed, vim) return true elif command.type == MOTION or command.type == OPERATOR_MOTION: var motion_args = command.get("motion_args", {}) if command.type == OPERATOR_MOTION: var operator_args = command.get("operator_args", {}) operator_args.original_pos = start input_state.operator = command.operator input_state.operator_args = operator_args if command.keys[-1] == "{char}": motion_args.selected_character = char(input_state.buffer.back().unicode) # Handle the motion and get the new cursor position var new_pos = process_motion(command.motion, motion_args, ed, vim) if new_pos == null: return true # In some cases (text object), we need to override the start position if new_pos is TextRange: start = new_pos.from new_pos = new_pos.to var inclusive : bool = motion_args.get("inclusive", false) var jump_forward := start.compares_to(new_pos) < 0 if vim_session.visual_mode: # Visual mode if vim_session.visual_line: if jump_forward: ed.select(start.line, 0, new_pos.line, INF_COL) else: ed.select(start.line, INF_COL, new_pos.line, -1) else: ed.select_by_pos2(start, new_pos) else: # Normal mode if input_state.operator.is_empty(): # Motion only ed.jump_to(new_pos.line, new_pos.column) else: # Operator motion # Check if we need to exlude the last character in selection if not inclusive: if jump_forward: new_pos = new_pos.left() else: start = start.left() ed.select_by_pos2(start, new_pos) process_operator(input_state.operator, input_state.operator_args, ed, vim) return true elif command.type == OPERATOR: var operator_args = command.get("operator_args", {}) operator_args.original_pos = start if vim.current.visual_mode: operator_args.line_wise = vim.current.visual_line process_operator(command.operator, operator_args, ed, vim) vim.current.enter_normal_mode() return true # Otherwise, we are in normal mode if input_state.operator.is_empty(): # We are not fully done yet, need to wait for the motion input_state.operator = command.operator input_state.operator_args = operator_args input_state.buffer.clear() return false # Line wise operation if input_state.operator == command.operator: operator_args.line_wise = true var new_pos : Position = process_motion("expand_to_line", {}, ed, vim) ed.select(start.line, 0, new_pos.line, new_pos.column) process_operator(command.operator, operator_args, ed, vim) return true return false func process_action(action: String, action_args: Dictionary, ed: EditorAdaptor, vim: Vim) -> void: if DEBUGGING: print(" Action: %s %s" % [action, action_args]) action_args.repeat = max(1, vim.current.input_state.get_repeat()) Callable(Command, action).call(action_args, ed, vim) if vim.current.visual_mode and action != "enter_visual_mode": vim.current.enter_normal_mode() func process_operator(operator: String, operator_args: Dictionary, ed: EditorAdaptor, vim: Vim) -> void: if DEBUGGING: print(" Operator %s %s on %s" % [operator, operator_args, ed.selection()]) # Perform operation Callable(Command, operator).call(operator_args, ed, vim) if operator_args.get("maintain_position", false): var original_pos = operator_args.get("original_pos") ed.jump_to(original_pos.line, original_pos.column) func process_motion(motion: String, motion_args: Dictionary, ed: EditorAdaptor, vim: Vim) -> Variant: # Get current position var cur := Position.new(ed.curr_line(), ed.curr_column()) # In Godot 4.3, CodeEdit.select moves cursor as well. If we select forward, the cursor will be positioned at the next column of the last selected column. # But for VIM in the same case, the cursor position is exactly the last selected column. So we move back by one column when we considering the current position. if vim.current.visual_mode: if ed.code_editor.get_selection_origin_column() < ed.code_editor.get_caret_column(): cur.column -= 1 # Prepare motion args var user_repeat = vim.current.input_state.get_repeat() if user_repeat > 0: motion_args.repeat = user_repeat motion_args.repeat_is_explicit = true else: motion_args.repeat = 1 motion_args.repeat_is_explicit = false # Calculate new position var result = Callable(Command, motion).call(cur, motion_args, ed, vim) if result is Position: var new_pos : Position = result if new_pos.column == INF_COL: # INF_COL means the last column new_pos.column = ed.last_column(new_pos.line) if motion_args.get('to_jump_list', false): vim.current.jump_list.add(cur, new_pos) # Save last motion vim.current.last_motion = motion if DEBUGGING: print(" Motion: %s %s to %s" % [motion, motion_args, result]) return result