diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..8ad74f7
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,2 @@
+# Normalize EOL for all files that Git considers text files.
+* text=auto eol=lf
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..58c1eec
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,19 @@
+# ---> Godot
+# Godot 4+ specific ignores
+.godot/
+/android/
+
+# Godot-specific ignores
+.import/
+export.cfg
+export_presets.cfg
+
+# Imported translations (automatically generated from CSV files)
+*.translation
+
+# Mono-specific ignores
+.mono/
+data_*/
+mono_crash.*.json
+
+
diff --git a/addons/godot-vim/godot-vim.gd b/addons/godot-vim/godot-vim.gd
new file mode 100644
index 0000000..f6733d5
--- /dev/null
+++ b/addons/godot-vim/godot-vim.gd
@@ -0,0 +1,1702 @@
+@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
diff --git a/addons/godot-vim/icon.svg b/addons/godot-vim/icon.svg
new file mode 100644
index 0000000..9831b04
--- /dev/null
+++ b/addons/godot-vim/icon.svg
@@ -0,0 +1,179 @@
+
+
diff --git a/addons/godot-vim/icon.svg.import b/addons/godot-vim/icon.svg.import
new file mode 100644
index 0000000..7f441b3
--- /dev/null
+++ b/addons/godot-vim/icon.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://kinglqst816b"
+path="res://.godot/imported/icon.svg-5744b51718b6a64145ec5798797f7631.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/godot-vim/icon.svg"
+dest_files=["res://.godot/imported/icon.svg-5744b51718b6a64145ec5798797f7631.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/addons/godot-vim/plugin.cfg b/addons/godot-vim/plugin.cfg
new file mode 100644
index 0000000..9a7f5d3
--- /dev/null
+++ b/addons/godot-vim/plugin.cfg
@@ -0,0 +1,7 @@
+[plugin]
+
+name="godot-vim"
+description="VIM bindings for godot4"
+author="Original: Josh N; Forked by Wenqiang Wang"
+version="4.3.0"
+script="godot-vim.gd"
diff --git a/addons/godot_vim/command_line.gd b/addons/godot_vim/command_line.gd
new file mode 100644
index 0000000..75e0f71
--- /dev/null
+++ b/addons/godot_vim/command_line.gd
@@ -0,0 +1,93 @@
+extends LineEdit
+
+const Cursor = preload("res://addons/godot_vim/cursor.gd")
+const StatusBar = preload("res://addons/godot_vim/status_bar.gd")
+const Constants = preload("res://addons/godot_vim/constants.gd")
+const MODE = Constants.Mode
+
+const Goto = preload("res://addons/godot_vim/commands/goto.gd")
+const Find = preload("res://addons/godot_vim/commands/find.gd")
+
+var code_edit: CodeEdit
+var cursor: Cursor
+var status_bar: StatusBar
+var globals: Dictionary
+
+var is_paused: bool = false
+var search_pattern: String = ""
+
+
+func _ready():
+ placeholder_text = "Enter command..."
+ show()
+ text_submitted.connect(_on_text_submitted)
+ text_changed.connect(_on_text_changed)
+ editable = true
+
+
+func set_command(cmd: String):
+ text = cmd
+ caret_column = text.length()
+
+
+func _on_text_changed(cmd: String):
+ if !cmd.begins_with("/"):
+ return
+ # Update search
+ var pattern: String = cmd.substr(1)
+ var rmatch: RegExMatch = globals.vim_plugin.search_regex(
+ code_edit, pattern, cursor.get_caret_pos() + Vector2i.RIGHT
+ )
+ if rmatch == null:
+ code_edit.remove_secondary_carets()
+ return
+
+ var pos: Vector2i = globals.vim_plugin.idx_to_pos(code_edit, rmatch.get_start())
+ if code_edit.get_caret_count() < 2:
+ code_edit.add_caret(pos.y, pos.x)
+ code_edit.select(pos.y, pos.x, pos.y, pos.x + rmatch.get_string().length(), 1)
+ # code_edit.center_viewport_to_caret(1) # why no work, eh?
+
+ code_edit.scroll_vertical = (
+ code_edit.get_scroll_pos_for_line(pos.y) - code_edit.get_visible_line_count() / 2
+ )
+
+
+func handle_command(cmd: String):
+ if cmd.begins_with("/"):
+ var find = Find.new()
+ find.execute(globals, cmd)
+ return
+
+ if cmd.trim_prefix(":").is_valid_int():
+ var goto = Goto.new()
+ goto.execute(globals, cmd.trim_prefix(":"))
+ return
+
+ if globals.vim_plugin.dispatch(cmd) == OK:
+ set_paused(true)
+ return
+
+ status_bar.display_error('Unknown command: "%s"' % [cmd.trim_prefix(":")])
+ set_paused(true)
+
+
+func close():
+ hide()
+ clear()
+ set_paused(false)
+
+
+# Wait for user input
+func set_paused(paused: bool):
+ is_paused = paused
+ text = "Press ENTER to continue" if is_paused else ""
+ editable = !is_paused
+
+
+func _on_text_submitted(new_text: String):
+ if is_paused:
+ cursor.set_mode(MODE.NORMAL)
+ status_bar.main_label.text = ""
+ return
+ handle_command(new_text)
diff --git a/addons/godot_vim/commands/delmarks.gd b/addons/godot_vim/commands/delmarks.gd
new file mode 100644
index 0000000..e98df76
--- /dev/null
+++ b/addons/godot_vim/commands/delmarks.gd
@@ -0,0 +1,14 @@
+const Constants = preload("res://addons/godot_vim/constants.gd")
+const StatusBar = preload("res://addons/godot_vim/status_bar.gd")
+const MODE = Constants.Mode
+
+const LINE_START_IDX: int = 8
+const COL_START_IDX: int = 16
+const FILE_START_IDX: int = 25
+
+
+func execute(api: Dictionary, _args):
+ api.marks = {}
+
+ api.status_bar.display_text("Deleted all marks")
+ api.cursor.set_mode(MODE.NORMAL)
diff --git a/addons/godot_vim/commands/find.gd b/addons/godot_vim/commands/find.gd
new file mode 100644
index 0000000..bfbc5fb
--- /dev/null
+++ b/addons/godot_vim/commands/find.gd
@@ -0,0 +1,16 @@
+const Constants = preload("res://addons/godot_vim/constants.gd")
+const MODE = Constants.Mode
+
+
+func execute(api: Dictionary, args: String):
+ api.command_line.search_pattern = args.substr(1)
+ var rmatch: RegExMatch = api.vim_plugin.search_regex(
+ api.code_edit, api.command_line.search_pattern, api.cursor.get_caret_pos() + Vector2i.RIGHT
+ )
+ if rmatch != null:
+ var pos: Vector2i = api.vim_plugin.idx_to_pos(api.code_edit, rmatch.get_start())
+ api.cursor.set_caret_pos(pos.y, pos.x)
+ # api.code_edit.center_viewport_to_caret()
+ else:
+ api.status_bar.display_error('Pattern not found: "%s"' % [api.command_line.search_pattern])
+ api.cursor.set_mode(MODE.NORMAL)
diff --git a/addons/godot_vim/commands/goto.gd b/addons/godot_vim/commands/goto.gd
new file mode 100644
index 0000000..9794bda
--- /dev/null
+++ b/addons/godot_vim/commands/goto.gd
@@ -0,0 +1,7 @@
+const Constants = preload("res://addons/godot_vim/constants.gd")
+const MODE = Constants.Mode
+
+
+func execute(api, args):
+ api.cursor.set_caret_pos(args.to_int() - 1, 0)
+ api.cursor.set_mode(MODE.NORMAL)
diff --git a/addons/godot_vim/commands/marks.gd b/addons/godot_vim/commands/marks.gd
new file mode 100644
index 0000000..cf3c1c0
--- /dev/null
+++ b/addons/godot_vim/commands/marks.gd
@@ -0,0 +1,61 @@
+const Constants = preload("res://addons/godot_vim/constants.gd")
+const StatusBar = preload("res://addons/godot_vim/status_bar.gd")
+const MODE = Constants.Mode
+
+const LINE_START_IDX: int = 8
+const COL_START_IDX: int = 16
+const FILE_START_IDX: int = 25
+
+
+## Display format:
+## (1) LINE_START_IDX
+## (2) COL_START_IDX
+## (3) FILE_START_IDX
+##
+## List of all marks:
+## (1) (2) (3)
+## | | |
+## mark line col file
+## a 123 456 res://some_file
+func row_string(mark: String, line: String, col: String, file: String) -> String:
+ var text: String = mark
+ text += " ".repeat(LINE_START_IDX - mark.length()) + line
+ text += " ".repeat(COL_START_IDX - text.length()) + col
+ text += " ".repeat(FILE_START_IDX - text.length()) + file
+ return text
+
+
+func mark_string(key: String, m: Dictionary) -> String:
+ var pos: Vector2i = m.get("pos", Vector2i())
+ var file: String = m.get("file", "")
+ return row_string(key, str(pos.y), str(pos.x), file)
+
+
+func execute(api, _args):
+ var marks: Dictionary = api.get("marks", {})
+ if marks.is_empty():
+ api.status_bar.display_error("No marks set")
+ api.cursor.set_mode(MODE.NORMAL)
+ return
+
+ var text: String = "[color=%s]List of all marks[/color]" % StatusBar.SPECIAL_COLOR
+ text += "\n" + row_string("mark", "line", "col", "file")
+
+ # Display user-defined marks first (alphabet)
+ for key in marks.keys():
+ if !is_key_alphabet(key):
+ continue
+ text += "\n" + mark_string(key, marks[key])
+
+ # Then display 'number' marks
+ for key in marks.keys():
+ if is_key_alphabet(key) or key == "-1":
+ continue
+ text += "\n" + mark_string(key, marks[key])
+
+ api.status_bar.display_text(text)
+
+
+func is_key_alphabet(key: String) -> bool:
+ var unicode: int = key.unicode_at(0)
+ return (unicode >= 65 and unicode <= 90) or (unicode >= 97 and unicode <= 122)
diff --git a/addons/godot_vim/commands/movecolumn.gd b/addons/godot_vim/commands/movecolumn.gd
new file mode 100644
index 0000000..4d0915d
--- /dev/null
+++ b/addons/godot_vim/commands/movecolumn.gd
@@ -0,0 +1,2 @@
+func execute(api, args):
+ api.cursor.move_column(int(args))
diff --git a/addons/godot_vim/commands/moveline.gd b/addons/godot_vim/commands/moveline.gd
new file mode 100644
index 0000000..23c20a0
--- /dev/null
+++ b/addons/godot_vim/commands/moveline.gd
@@ -0,0 +1,2 @@
+func execute(api, args):
+ api.cursor.move_line(int(args))
diff --git a/addons/godot_vim/commands/reload.gd b/addons/godot_vim/commands/reload.gd
new file mode 100644
index 0000000..8728787
--- /dev/null
+++ b/addons/godot_vim/commands/reload.gd
@@ -0,0 +1,4 @@
+func execute(api: Dictionary, _args):
+ print("[Godot VIM] Reloading...")
+ api.status_bar.display_text("Reloading plugin...")
+ api.vim_plugin.initialize(true)
diff --git a/addons/godot_vim/commands/remap.gd b/addons/godot_vim/commands/remap.gd
new file mode 100644
index 0000000..13f9c2b
--- /dev/null
+++ b/addons/godot_vim/commands/remap.gd
@@ -0,0 +1,9 @@
+const Constants = preload("res://addons/godot_vim/constants.gd")
+const StatusBar = preload("res://addons/godot_vim/status_bar.gd")
+
+
+func execute(api: Dictionary, _args):
+ print("[Godot VIM] Please run :reload in the command line after changing your keybinds")
+ var script: Script = api.key_map.get_script()
+ # Line 45 is where KeyMap::map() is
+ EditorInterface.edit_script(script, 40, 0, false)
diff --git a/addons/godot_vim/commands/w.gd b/addons/godot_vim/commands/w.gd
new file mode 100644
index 0000000..505b554
--- /dev/null
+++ b/addons/godot_vim/commands/w.gd
@@ -0,0 +1,17 @@
+const Constants = preload("res://addons/godot_vim/constants.gd")
+const MODE = Constants.Mode
+
+
+func execute(api, _args: String):
+ #EditorInterface.save_scene()
+ press_save_shortcut()
+ api.cursor.set_mode(MODE.NORMAL)
+
+
+func press_save_shortcut():
+ var a = InputEventKey.new()
+ a.keycode = KEY_S
+ a.ctrl_pressed = true
+ a.alt_pressed = true
+ a.pressed = true
+ Input.parse_input_event(a)
diff --git a/addons/godot_vim/commands/wa.gd b/addons/godot_vim/commands/wa.gd
new file mode 100644
index 0000000..5329b08
--- /dev/null
+++ b/addons/godot_vim/commands/wa.gd
@@ -0,0 +1,17 @@
+const Constants = preload("res://addons/godot_vim/constants.gd")
+const MODE = Constants.Mode
+
+
+func execute(api, _args: String):
+ #EditorInterface.save_scene()
+ press_save_shortcut()
+ api.cursor.set_mode(MODE.NORMAL)
+
+
+func press_save_shortcut():
+ var a = InputEventKey.new()
+ a.keycode = KEY_S
+ a.shift_pressed = true
+ a.alt_pressed = true
+ a.pressed = true
+ Input.parse_input_event(a)
diff --git a/addons/godot_vim/constants.gd b/addons/godot_vim/constants.gd
new file mode 100644
index 0000000..c3e6101
--- /dev/null
+++ b/addons/godot_vim/constants.gd
@@ -0,0 +1,19 @@
+enum Mode { NORMAL = 0, INSERT, VISUAL, VISUAL_LINE, COMMAND }
+
+enum Language {
+ GDSCRIPT,
+ SHADER,
+}
+
+const KEYWORDS: String = ".,\"'-=+!@#$%^&*()[]{}?~/\\<>:;`"
+const DIGITS: String = "0123456789"
+const SPACES: String = " \t"
+const PAIRS: Dictionary = {
+ '"': '"',
+ "'": "'",
+ "`": "`",
+ "(": ")",
+ "[": "]",
+ "{": "}",
+}
+const BRACES: String = "([{}])"
diff --git a/addons/godot_vim/cursor.gd b/addons/godot_vim/cursor.gd
new file mode 100644
index 0000000..1fec6d6
--- /dev/null
+++ b/addons/godot_vim/cursor.gd
@@ -0,0 +1,1135 @@
+extends Control
+
+const CommandLine = preload("res://addons/godot_vim/command_line.gd")
+const StatusBar = preload("res://addons/godot_vim/status_bar.gd")
+const Constants = preload("res://addons/godot_vim/constants.gd")
+const Mode = Constants.Mode
+const KEYWORDS = Constants.KEYWORDS
+const SPACES = Constants.SPACES
+const LANGUAGE = Constants.Language
+
+var code_edit: CodeEdit
+var language: LANGUAGE = LANGUAGE.GDSCRIPT
+var command_line: CommandLine
+var status_bar: StatusBar
+var key_map: KeyMap
+
+var mode: Mode = Mode.NORMAL
+# For visual modes:
+# `selection_from` is the origin point of the selection
+# code_edit's caret pos is the end point of the selection (the one the user can move)
+var selection_from: Vector2i = Vector2i()
+
+var globals: Dictionary = {}
+
+
+func _init():
+ set_focus_mode(FOCUS_ALL)
+
+
+func _ready():
+ code_edit.connect("focus_entered", focus_entered)
+ code_edit.connect("caret_changed", cursor_changed)
+ call_deferred("set_mode", Mode.NORMAL)
+
+
+func cursor_changed():
+ draw_cursor()
+
+
+func focus_entered():
+ if mode == Mode.NORMAL:
+ code_edit.release_focus()
+ self.grab_focus()
+
+
+func reset_normal():
+ set_mode(Mode.NORMAL)
+ selection_from = Vector2i.ZERO
+ set_column(code_edit.get_caret_column())
+
+
+func _input(event: InputEvent):
+ if Input.is_key_pressed(KEY_ESCAPE):
+ reset_normal()
+ status_bar.clear()
+ return
+
+ draw_cursor()
+ if !has_focus() and mode != Mode.INSERT:
+ return
+ if !event is InputEventKey:
+ return
+ if !event.pressed:
+ return
+ if mode == Mode.COMMAND:
+ return
+
+ # See KeyMap.key_map, KeyMap.register_event()
+ var registered_cmd: Dictionary = key_map.register_event(event, mode)
+
+ # Display keys in status bar
+ if mode == Mode.NORMAL or is_mode_visual(mode):
+ status_bar.set_keys_text(key_map.get_input_stream_as_string())
+ else:
+ status_bar.clear_keys()
+
+ if KeyMap.is_cmd_valid(registered_cmd):
+ code_edit.cancel_code_completion()
+ get_viewport().set_input_as_handled()
+
+
+# Mostly used for commands like "w", "b", and "e"
+func get_word_edge_pos(
+ from_line: int, from_col: int, forward: bool, word_end: bool, big_word: bool
+) -> Vector2i:
+ var search_dir: int = int(forward) - int(!forward) # 1 if forward else -1
+ var line: int = from_line
+ # Think of `col` as the place in between the two chars we're testing
+ var col: int = from_col + search_dir + int(word_end) # Also nudge it once if checking word ends ("e" or "ge")
+ # Char groups: 0 = char is normal char, 1 = char is keyword, 2 = char is space
+ # Cancel 1st bit (keywords) if big word so that keywords and normal chars are treated the same
+ var big_word_mask: int = 0b10 if big_word else 0b11
+
+ var text: String = get_line_text(line)
+ while line >= 0 and line < code_edit.get_line_count():
+ while col >= 0 and col <= text.length():
+ # Get "group" of chars to the left and right of `col`
+ var left_char: String = " " if col == 0 else text[col - 1]
+ var right_char: String = " " if col == text.length() else text[col] # ' ' if eol; else, the char to the right
+ var lg: int = (
+ (int(KEYWORDS.contains(left_char)) | (int(SPACES.contains(left_char)) << 1))
+ & big_word_mask
+ )
+ var rg: int = (
+ (int(KEYWORDS.contains(right_char)) | (int(SPACES.contains(right_char)) << 1))
+ & big_word_mask
+ )
+
+ # Same as: if lg != rg and (lg if word_end else rg) != 2 but without branching
+ # (is different group) and (spaces don't count in the wrong direction)
+ if lg != rg and lg * int(word_end) + rg * int(!word_end) != 0b10:
+ return Vector2i(col - int(word_end), line)
+
+ col += search_dir
+ line += search_dir
+ text = get_line_text(line)
+ col = (text.length() - 1) * int(search_dir < 0)
+
+ return Vector2i(from_col, from_line)
+
+
+""" Rough explanation:
+forward and end -> criteria = current_empty and !previous_empty, no offset
+!forward and end -> criteria = !current_empty and previous_empty, +1 offset
+forward and !end -> criteria = !current_empty and previous_empty, -1 offset
+!forward and !end -> criteria = current_empty and !previous_empty, no offset
+
+criteria = (current_empty and !previous_empty)
+ if forward == end
+ else ( !(current_empty and !previous_empty) - search_dir )
+"""
+
+
+## Get the 'edge' or a paragraph (like with { or } motions)
+func get_paragraph_edge_pos(from_line: int, forward: bool, paragraph_end: bool) -> int:
+ var search_dir: int = int(forward) - int(!forward) # 1 if forward else -1
+ var line: int = from_line
+ var prev_empty: bool = code_edit.get_line(line).strip_edges().is_empty()
+ var f_eq_end: bool = forward == paragraph_end
+ line += search_dir
+
+ while line >= 0 and line < code_edit.get_line_count():
+ var current_empty: bool = code_edit.get_line(line).strip_edges().is_empty()
+ if f_eq_end:
+ if current_empty and !prev_empty:
+ return line
+ elif !current_empty and prev_empty:
+ return line - search_dir
+
+ prev_empty = current_empty
+ line += search_dir
+ return line
+
+
+# Get the 'edge' or a section (like with [[ or ]] motions)
+# See is_line_section()
+func get_section_edge_pos(from_line: int, forward: bool) -> Vector2i:
+ var search_dir: int = int(forward) - int(!forward)
+ var line: int = from_line
+ var is_prev_section: bool = is_line_section(code_edit.get_line(line))
+
+ line += search_dir
+ while line >= 0 and line < code_edit.get_line_count():
+ var text: String = code_edit.get_line(line)
+ if is_line_section(text) and !is_prev_section:
+ return Vector2i(text.length(), line)
+
+ is_prev_section = is_line_section(code_edit.get_line(line))
+ line += search_dir
+ return Vector2i(0, line)
+
+
+## Finds the next -- or previous is `forward = false` -- occurence of `char` in the line `line`,
+## starting from col `from_col`
+## Additionally, it can stop before the occurence with `stop_before = true`
+func find_char_in_line(
+ line: int, from_col: int, forward: bool, stop_before: bool, char: String
+) -> int:
+ var text: String = get_line_text(line)
+
+ # Search char
+ var col: int = text.find(char, from_col + 1) if forward else text.rfind(char, from_col - 1)
+ if col == -1: # Not found
+ return -1
+
+ # col + offset
+ # where offset = ( int(!forward) - int(forward) ) * int(stop_before)
+ # = 1 if forward, -1 if !forward, 0 otherwise
+ return col + (int(!forward) - int(forward)) * int(stop_before)
+
+
+## Finds the next -- or previous if `forward = false` -- occurence of any character in `chars`
+## if `chars` has only one character, it will look for that one
+func find_next_occurence_of_chars(
+ from_line: int,
+ from_col: int,
+ chars: String,
+ forward: bool,
+) -> Vector2i:
+ var p: Vector2i = Vector2i(from_col, from_line)
+ var line_count: int = code_edit.get_line_count()
+ var search_dir: int = int(forward) - int(!forward) # 1 if forward, -1 if backwards
+
+ var text: String = get_line_text(p.y)
+ while p.y >= 0 and p.y < line_count:
+ while p.x >= 0 and p.x < text.length():
+ if chars.contains(text[p.x]):
+ return p
+ p.x += search_dir
+ p.y += search_dir
+ text = get_line_text(p.y)
+ p.x = (text.length() - 1) * int(!forward) # 0 if forwards, EOL if backwards
+ # Not found
+ # i want optional typing to be in godot so bad
+ return Vector2i(-1, -1)
+
+
+## Finds the next / previous brace specified by `brace` and its closing `counterpart`
+## `force_inline` forces to look only in the line `from_line` (See constants.gd::INLINE_BRACKETS)
+## E.g.
+## brace = "(", counterpart = ")", forward = false, from_line and from_col = in between the brackets
+## will find the start of the set of parantheses the cursor is inside of
+func find_brace(
+ from_line: int,
+ from_col: int,
+ brace: String,
+ counterpart: String,
+ forward: bool,
+ force_inline: bool = false,
+) -> Vector2i:
+ var line_count: int = code_edit.get_line_count()
+ var d: int = int(forward) - int(!forward)
+
+ var p: Vector2i = Vector2i(from_col + d, from_line)
+ var stack: int = 0
+
+ var text: String = get_line_text(p.y)
+ while p.y >= 0 and p.y < line_count:
+ while p.x >= 0 and p.x < text.length():
+ var char: String = text[p.x]
+
+ if char == counterpart:
+ if stack == 0:
+ return p
+ stack -= 1
+ elif char == brace:
+ stack += 1
+ p.x += d
+
+ if force_inline:
+ return Vector2i(-1, -1)
+
+ p.y += d
+ text = get_line_text(p.y)
+ p.x = (text.length() - 1) * int(!forward) # 0 if forwards, EOL if backwards
+
+ # i want optional typing to be in godot so bad rn
+ return Vector2i(-1, -1)
+
+
+func get_comment_char() -> String:
+ match language:
+ LANGUAGE.SHADER:
+ return "//"
+ LANGUAGE.GDSCRIPT:
+ return "#"
+ _:
+ return "#"
+
+
+func set_line_commented(line: int, is_commented: bool):
+ var text: String = get_line_text(line)
+ # Don't comment if empty
+ if text.strip_edges().is_empty():
+ return
+
+ var ind: int = code_edit.get_first_non_whitespace_column(line)
+ if is_commented:
+ code_edit.set_line(line, text.insert(ind, get_comment_char() + " "))
+ return
+ # We use get_word_edge_pos() in case there's multiple '#'s
+ var start_col: int = get_word_edge_pos(line, ind, true, false, true).x
+ code_edit.select(line, ind, line, start_col)
+ code_edit.delete_selection()
+
+
+func is_line_commented(line: int) -> bool:
+ var text: String = get_line_text(line).strip_edges(true, false)
+ return text.begins_with(get_comment_char())
+
+
+func set_mode(m: int):
+ var old_mode: int = mode
+ mode = m
+ command_line.close()
+
+ match mode:
+ Mode.NORMAL:
+ code_edit.call_deferred("cancel_code_completion")
+ key_map.clear()
+
+ code_edit.remove_secondary_carets() # Secondary carets are used when searching with '/' (See command_line.gd)
+ code_edit.release_focus()
+ code_edit.deselect()
+ self.grab_focus()
+ status_bar.set_mode_text(Mode.NORMAL)
+
+ # Insert -> Normal
+ if old_mode == Mode.INSERT:
+ # code_edit.end_complex_operation() # See Mode.INSERT match arm below
+ move_column(-1)
+
+ Mode.VISUAL:
+ if old_mode != Mode.VISUAL_LINE:
+ selection_from = Vector2i(code_edit.get_caret_column(), code_edit.get_caret_line())
+ status_bar.set_mode_text(Mode.VISUAL)
+ update_visual_selection()
+
+ Mode.VISUAL_LINE:
+ if old_mode != Mode.VISUAL:
+ selection_from = Vector2i(code_edit.get_caret_column(), code_edit.get_caret_line())
+ status_bar.set_mode_text(Mode.VISUAL_LINE)
+ update_visual_selection()
+
+ Mode.COMMAND:
+ command_line.show()
+ command_line.call_deferred("grab_focus")
+ status_bar.set_mode_text(Mode.COMMAND)
+
+ Mode.INSERT:
+ code_edit.call_deferred("grab_focus")
+ status_bar.set_mode_text(Mode.INSERT)
+
+ # if old_mode == Mode.NORMAL:
+ # Complex operation so that entire insert mode actions can be undone
+ # with one undo
+ # code_edit.begin_complex_operation()
+
+ _:
+ push_error("[vim::cursor::set_mode()] Unknown mode %s" % mode)
+
+
+func move_line(offset: int):
+ set_line(get_line() + offset)
+
+
+func get_line() -> int:
+ return code_edit.get_caret_line()
+
+
+func get_line_text(line: int = -1) -> String:
+ if line == -1:
+ return code_edit.get_line(get_line())
+ return code_edit.get_line(line)
+
+
+func get_char_at(line: int, col: int) -> String:
+ var text: String = code_edit.get_line(line)
+ if col > 0 and col < text.length():
+ return text[col]
+ return ""
+
+
+func get_line_length(line: int = -1) -> int:
+ return get_line_text(line).length()
+
+
+func set_caret_pos(line: int, column: int):
+ set_line(line) # line has to be set before column
+ set_column(column)
+
+
+func get_caret_pos() -> Vector2i:
+ return Vector2i(code_edit.get_caret_column(), code_edit.get_caret_line())
+
+
+func set_line(position: int):
+ code_edit.set_caret_line(min(position, code_edit.get_line_count() - 1))
+
+ if is_mode_visual(mode):
+ update_visual_selection()
+
+
+func move_column(offset: int):
+ set_column(get_column() + offset)
+
+
+func get_column():
+ return code_edit.get_caret_column()
+
+
+func set_column(position: int):
+ code_edit.set_caret_column(min(get_line_length(), position))
+
+ if is_mode_visual(mode):
+ update_visual_selection()
+
+
+func select(from_line: int, from_col: int, to_line: int, to_col: int):
+ code_edit.select(from_line, from_col, to_line, to_col + 1)
+ selection_from = Vector2i(from_col, from_line)
+ set_caret_pos(to_line, to_col)
+ # status_bar.set_mode_text(Mode.VISUAL)
+
+
+func update_visual_selection():
+ var selection_to: Vector2i = Vector2i(code_edit.get_caret_column(), code_edit.get_caret_line())
+ if mode == Mode.VISUAL:
+ var backwards: bool = (
+ selection_to.x < selection_from.x
+ if selection_to.y == selection_from.y
+ else selection_to.y < selection_from.y
+ )
+ code_edit.select(
+ selection_from.y,
+ selection_from.x + int(backwards),
+ selection_to.y,
+ selection_to.x + int(!backwards)
+ )
+ elif mode == Mode.VISUAL_LINE:
+ var f: int = mini(selection_from.y, selection_to.y) - 1
+ var t: int = maxi(selection_from.y, selection_to.y)
+ code_edit.select(f, get_line_length(f), t, get_line_length(t))
+
+
+func is_mode_visual(m: int) -> bool:
+ return m == Mode.VISUAL or m == Mode.VISUAL_LINE
+
+
+func is_lowercase(text: String) -> bool:
+ return text == text.to_lower()
+
+
+func is_uppercase(text: String) -> bool:
+ return text == text.to_upper()
+
+
+func is_line_section(text: String) -> bool:
+ var t: String = text.strip_edges()
+
+ match language:
+ LANGUAGE.SHADER:
+ return t.ends_with("{") and !SPACES.contains(text.left(1))
+ LANGUAGE.GDSCRIPT:
+ return (
+ t.begins_with("func")
+ or t.begins_with("static func")
+ or t.begins_with("class")
+ or t.begins_with("#region")
+ )
+ _:
+ return false
+
+
+func get_stream_char(stream: String, idx: int) -> String:
+ return stream[idx] if stream.length() > idx else ""
+
+
+func draw_cursor():
+ if code_edit.is_dragging_cursor() and code_edit.get_selected_text() != "":
+ selection_from = Vector2i(
+ code_edit.get_selection_from_column(), code_edit.get_selection_from_line()
+ )
+
+ if code_edit.get_selected_text(0).length() > 1 and !is_mode_visual(mode):
+ code_edit.release_focus()
+ self.grab_focus()
+ set_mode(Mode.VISUAL)
+
+ if mode == Mode.INSERT:
+ if code_edit.has_selection(0):
+ code_edit.deselect(0)
+ return
+
+ if mode != Mode.NORMAL:
+ return
+
+ var line: int = code_edit.get_caret_line()
+ var column: int = code_edit.get_caret_column()
+ if column >= code_edit.get_line(line).length():
+ column -= 1
+ code_edit.set_caret_column(column)
+
+ code_edit.select(line, column, line, column + 1)
+
+
+#region COMMANDS
+
+#region MOTIONS
+# Motion commands must return a Vector2i with the cursor's new position
+
+
+## Moves the cursor horizontally
+## Args:
+## - "move_by": int
+## How many characters to move by
+func cmd_move_by_chars(args: Dictionary) -> Vector2i:
+ return Vector2i(get_column() + args.get("move_by", 0), get_line())
+
+
+## Moves the cursor vertically
+## Args:
+## - "move_by": int
+## How many lines to move by
+func cmd_move_by_lines(args: Dictionary) -> Vector2i:
+ return Vector2i(get_column(), get_line() + args.get("move_by", 0))
+
+
+## Moves the cursor vertically by a certain percentage of the screen / page
+## Args:
+## - "percentage": float
+## How much to move by
+## E.g. percentage = 0.5 will move down half a screen,
+## percentage = -1.0 will move up a full screen
+func cmd_move_by_screen(args: Dictionary) -> Vector2i:
+ var h: int = code_edit.get_visible_line_count()
+ var amt: int = int(h * args.get("percentage", 0.0))
+ return Vector2i(get_column(), get_line() + amt)
+
+
+## Moves the cursor by word
+## Args:
+## - "forward": bool
+## Whether to move forwards (right) or backwards (left)
+## - "word_end": bool
+## Whether to move to the end of a word
+## - "big_word": bool
+## Whether to ignore keywords like ";", ",", "." (See KEYWORDS in constants.gd)
+func cmd_move_by_word(args: Dictionary) -> Vector2i:
+ return get_word_edge_pos(
+ get_line(),
+ get_column(),
+ args.get("forward", true),
+ args.get("word_end", false),
+ args.get("big_word", false)
+ )
+
+
+## Moves the cursor by paragraph
+## Args:
+## - "forward": bool
+## Whether to move forward (down) or backward (up)
+func cmd_move_by_paragraph(args: Dictionary) -> Vector2i:
+ var forward: bool = args.get("forward", false)
+ var line: int = get_paragraph_edge_pos(get_line(), forward, forward)
+ return Vector2i(0, line)
+
+
+## Moves the cursor to the start of the line
+## This is the VIM equivalent of "0"
+func cmd_move_to_bol(_args: Dictionary) -> Vector2i:
+ return Vector2i(0, get_line())
+
+
+## Moves the cursor to the end of the line
+## This is the VIM equivalent of "$"
+func cmd_move_to_eol(args: Dictionary) -> Vector2i:
+ return Vector2i(get_line_length(), get_line())
+
+
+## Moves the cursor to the first non-whitespace character in the current line
+## This is the VIM equivalent of "^"
+func cmd_move_to_first_non_whitespace_char(args: Dictionary) -> Vector2i:
+ return Vector2i(code_edit.get_first_non_whitespace_column(get_line()), get_line())
+
+
+## Moves the cursor to the start of the file
+## This is the VIM equivalent of "gg"
+func cmd_move_to_bof(args: Dictionary) -> Vector2i:
+ return Vector2i(0, 0)
+
+
+## Moves the cursor to the end of the file
+## This is the VIM equivalent of "G"
+func cmd_move_to_eof(args: Dictionary) -> Vector2i:
+ return Vector2i(0, code_edit.get_line_count())
+
+
+func cmd_move_to_center_of_line(_args: Dictionary) -> Vector2i:
+ var l: int = get_line()
+ return Vector2i(get_line_length(l) / 2, l)
+
+
+## Repeats the last '/' search
+## This is the VIM equivalent of "n" and "N"
+## Args:
+## - "forward": bool
+## Whether to search down (true) or up (false)
+func cmd_find_again(args: Dictionary) -> Vector2i:
+ if command_line.search_pattern.is_empty():
+ return get_caret_pos()
+
+ var rmatch: RegExMatch
+ if args.get("forward", false):
+ rmatch = globals.vim_plugin.search_regex(
+ code_edit, command_line.search_pattern, get_caret_pos() + Vector2i.RIGHT
+ )
+ else:
+ rmatch = globals.vim_plugin.search_regex_backwards(
+ code_edit, command_line.search_pattern, get_caret_pos() + Vector2i.LEFT
+ )
+
+ if rmatch == null:
+ return get_caret_pos()
+ return globals.vim_plugin.idx_to_pos(code_edit, rmatch.get_start())
+
+
+## Jumps to a character in the current line
+## This is the VIM equivalent of f, F, t, ant T
+## Args:
+## - "selected_char": String
+## The character to look for
+## - "forward": bool
+## Whether to search right (true) or left (false)
+## - "stop_before": bool
+## Whether to stop before [selected_char]
+func cmd_find_in_line(args: Dictionary) -> Vector2i:
+ var line: int = get_line()
+ var col: int = find_char_in_line(
+ line,
+ get_column(),
+ args.get("forward", false),
+ args.get("stop_before", false),
+ args.get("selected_char", "")
+ )
+
+ globals.last_search = args
+
+ if col >= 0:
+ return Vector2i(col, line)
+ return Vector2i(get_column(), line)
+
+
+## Repeats the last inline search
+## This is the VIM equivalent of ";" and ","
+## Args:
+## - "invert": bool
+## Whether search in the opposite direction of the last search
+func cmd_find_in_line_again(args_mut: Dictionary) -> Vector2i:
+ # 'mut' ('mutable') because 'args' will be changed
+ # The reason for that is because the arg 'inclusive' is dependant on the last search
+ # and will be used with Operators
+ if !globals.has("last_search"):
+ return get_caret_pos()
+
+ var last_search: Dictionary = globals.last_search
+ var line: int = get_line()
+ var col: int = find_char_in_line(
+ line,
+ get_column(),
+ last_search.get("forward", false) != args_mut.get("invert", false), # Invert 'forward' if necessary (xor)
+ last_search.get("stop_before", false),
+ last_search.get("selected_char", "")
+ )
+
+ args_mut.inclusive = globals.last_search.get("inclusive", false)
+ if col >= 0:
+ return Vector2i(col, line)
+ return Vector2i(get_column(), line)
+
+
+## Moves the cursor by section (VIM equivalent of [[ and ]])
+## Sections are defined by the following keywords:
+## - "func"
+## - "class"
+## - "#region"
+## See also is_line_section()
+##
+## Args:
+## - "forward": bool
+## Whether to move forward (down) or backward (up)
+func cmd_move_by_section(args: Dictionary) -> Vector2i:
+ var section_edge: Vector2i = get_section_edge_pos(get_line(), args.get("forward", false))
+ return section_edge
+
+
+## Corresponds to the % motion in VIM
+func cmd_jump_to_next_brace_pair(_args: Dictionary) -> Vector2i:
+ const PAIRS = Constants.PAIRS
+ var p: Vector2i = get_caret_pos()
+
+ var p0: Vector2i = find_next_occurence_of_chars(p.y, p.x, Constants.BRACES, true)
+ # Not found
+ if p0.x < 0 or p0.y < 0:
+ return p
+
+ var brace: String = code_edit.get_line(p0.y)[p0.x]
+ # Whether this brace is an opening or closing brace. i.e. ([{ or }])
+ var is_closing_brace: bool = PAIRS.values().has(brace)
+ var closing_counterpart: String = ""
+ if is_closing_brace:
+ var idx: int = PAIRS.values().find(brace)
+ if idx != -1:
+ closing_counterpart = brace
+ brace = PAIRS.keys()[idx]
+ else:
+ closing_counterpart = PAIRS.get(brace, "")
+ if closing_counterpart.is_empty():
+ push_error("[GodotVIM] Failed to get counterpart for brace: ", brace)
+ return p
+
+ var p1: Vector2i = (
+ find_brace(p0.y, p0.x, closing_counterpart, brace, false)
+ if is_closing_brace
+ else find_brace(p0.y, p0.x, brace, closing_counterpart, true)
+ )
+
+ if p1 == Vector2i(-1, -1):
+ return p0
+
+ return p1
+
+
+#region TEXT OBJECTS
+# Text Object commands must return two Vector2is with the cursor start and end position
+
+
+## Get the bounds of the text object specified in args
+## Args:
+## - "object": String
+## The text object to select. Should ideally be a key in constants.gd::PAIRS but doesn't have to
+## If "object" is not in constants.gd::PAIRS, then "counterpart" must be specified
+## - "counterpart": String (optional)
+## The end key of the text object
+## - "inline": bool (default = false)
+## Forces the search to occur only in the current line
+## - "around": bool (default = false)
+## Whether to select around (e.g. ab, aB, a[, a] in VIM)
+func cmd_text_object(args: Dictionary) -> Array[Vector2i]:
+ var p: Vector2i = get_caret_pos()
+
+ # Get start and end keys
+ if !args.has("object"):
+ push_error("[GodotVim] Error on cmd_text_object: No object selected")
+ return [p, p]
+
+ var obj: String = args.object
+ var counterpart: String
+ if args.has("counterpart"):
+ counterpart = args.counterpart
+ elif Constants.PAIRS.has(obj):
+ counterpart = Constants.PAIRS[obj]
+ else:
+ push_error(
+ '[GodotVim] Error on cmd_text_object: Invalid brace pair: "',
+ obj,
+ '". You can specify an end key with the argument `counterpart: String`'
+ )
+ return [p, p]
+
+ var inline: bool = args.get("inline", false)
+
+ # Deal with edge case where the cursor is already on the end
+ var p0x = p.x - 1 if get_char_at(p.y, p.x) == counterpart else p.x
+ # Look backwards to find start
+ var p0: Vector2i = find_brace(p.y, p0x, counterpart, obj, false, inline)
+
+ # Not found; try to look forward then
+ if p0.x == -1:
+ if inline:
+ var col: int = find_char_in_line(p.y, p0x, true, false, obj)
+ p0 = Vector2i(col, p.y)
+ else:
+ p0 = find_next_occurence_of_chars(p.y, p0x, obj, true)
+
+ if p0.x == -1:
+ return [p, p]
+
+ # Look forwards to find end
+ var p1: Vector2i = find_brace(p0.y, p0.x, obj, counterpart, true, inline)
+
+ if p1 == Vector2i(-1, -1):
+ return [p, p]
+
+ if args.get("around", false):
+ return [p0, p1]
+ return [p0 + Vector2i.RIGHT, p1 + Vector2i.LEFT]
+
+
+## Corresponds to the iw, iW, aw, aW motions in regular VIM
+## Args:
+## - "around": bool (default = false)
+## Whether to select around words (aw, aW in VIM)
+## - "big_word": bool (default = false)
+## Whether this is a big word motion (iW, aW in VIM)
+func cmd_text_object_word(args: Dictionary) -> Array[Vector2i]:
+ var is_big_word: bool = args.get("big_word", false)
+
+ var p: Vector2i = get_caret_pos()
+ var p0 = get_word_edge_pos(p.y, p.x + 1, false, false, is_big_word)
+ var p1 = get_word_edge_pos(p.y, p.x - 1, true, true, is_big_word)
+
+ if !args.get("around", false):
+ # Inner word (iw, iW)
+ return [p0, p1]
+
+ # Around word (aw, aW)
+ var text: String = get_line_text(p.y)
+ # Whether char to the RIGHT is a space
+ var next_char_is_space: bool = SPACES.contains(text[mini(p1.x + 1, text.length() - 1)])
+ if next_char_is_space:
+ p1.x = get_word_edge_pos(p1.y, p1.x, true, false, false).x - 1
+ return [p0, p1]
+
+ # Whether char to the LEFT is a space
+ next_char_is_space = SPACES.contains(text[maxi(p0.x - 1, 0)])
+ if next_char_is_space:
+ p0.x = get_word_edge_pos(p0.y, p0.x, false, true, false).x + 1
+ return [p0, p1]
+
+ return [p0, p1]
+
+
+## Warning: changes the current mode to VISUAL_LINE
+## Args:
+## - "around": bool (default = false)
+## Whether to select around paragraphs (ap in VIM)
+func cmd_text_object_paragraph(args: Dictionary) -> Array[Vector2i]:
+ var p: Vector2i = get_caret_pos()
+ var p0: Vector2i = Vector2i(0, get_paragraph_edge_pos(p.y, false, false) + 1)
+ var p1: Vector2i = Vector2i(0, get_paragraph_edge_pos(p.y, true, true) - 1)
+
+ if !args.get("around", false):
+ # Inner paragraph (ip)
+ set_mode(Mode.VISUAL_LINE)
+ return [p0, p1]
+
+ # Extend downwards
+ if p1.y < code_edit.get_line_count() - 1:
+ p1.y = get_paragraph_edge_pos(p1.y, true, false)
+ # Extend upwards
+ elif p0.y > 0:
+ p0.y = get_paragraph_edge_pos(p0.y - 1, false, true)
+
+ set_mode(Mode.VISUAL_LINE)
+ return [p0, p1]
+
+
+#endregion TEXT OBJECTS
+
+#endregion MOTIONS
+
+#region ACTIONS
+
+
+## Enters Insert mode
+## Args:
+## - (optional) "offset": String
+## Either of:
+## "after": Enter insert mode after the selected character (VIM equivalent: a)
+## "bol": Enter insert mode at the beginning of this line (VIM equivalent: I)
+## "eol": Enter insert mode at the end of this line (VIM equivalent: A)
+## "new_line_below": Insert at a new line below (VIM equivalent: o)
+## "new_line_above": Insert at a new line above (VIM equivalent: O)
+## defaults to "in_place": Enter insert mode before the selected character (VIM equivalent: i)
+func cmd_insert(args: Dictionary):
+ set_mode(Mode.INSERT)
+ var offset: String = args.get("offset", "in_place")
+
+ if offset == "after":
+ move_column(1)
+ elif offset == "bol":
+ set_column(code_edit.get_first_non_whitespace_column(get_line()))
+ elif offset == "eol":
+ set_column(get_line_length())
+ elif offset == "new_line_below":
+ var line: int = code_edit.get_caret_line()
+ var ind: int = (
+ code_edit.get_first_non_whitespace_column(line)
+ + int(code_edit.get_line(line).ends_with(":"))
+ )
+ code_edit.insert_line_at(
+ line + int(line < code_edit.get_line_count() - 1), "\t".repeat(ind)
+ )
+ move_line(+1)
+ set_column(ind)
+ set_mode(Mode.INSERT)
+ elif offset == "new_line_above":
+ var ind: int = code_edit.get_first_non_whitespace_column(code_edit.get_caret_line())
+ code_edit.insert_line_at(code_edit.get_caret_line(), "\t".repeat(ind))
+ move_line(-1)
+ set_column(ind)
+ set_mode(Mode.INSERT)
+
+
+## Switches to Normal mode
+## Args:
+## - (optional) "backspaces" : int
+## Number of times to backspace (e.g. once with 'jk')
+## - (optional) "offset" : int
+## How many colums to move the caret
+func cmd_normal(args: Dictionary):
+ for __ in args.get("backspaces", 0):
+ code_edit.backspace()
+ reset_normal()
+ if args.has("offset"):
+ move_column(args.offset)
+
+
+## Switches to Visual mode
+## if "line_wise": bool (optional) is true, it will switch to VisualLine instead
+func cmd_visual(args: Dictionary):
+ if args.get("line_wise", false):
+ set_mode(Mode.VISUAL_LINE)
+ else:
+ set_mode(Mode.VISUAL)
+
+
+## Switches the current mode to COMMAND mode
+## Args:
+## - Empty -> Enter command mode normally
+## - { "command" : "[cmd]" } -> Enter command mode with the command "[cmd]" already typed in
+func cmd_command(args: Dictionary):
+ set_mode(Mode.COMMAND)
+ if args.has("command"):
+ command_line.set_command(args.command)
+ else:
+ command_line.set_command(":")
+
+
+func cmd_undo(_args: Dictionary):
+ code_edit.undo()
+ set_mode(Mode.NORMAL)
+
+
+func cmd_redo(_args: Dictionary):
+ code_edit.redo()
+ if mode != Mode.NORMAL:
+ set_mode(Mode.NORMAL)
+
+
+## Join the current line with the next one
+func cmd_join(_args: Dictionary):
+ var line: int = code_edit.get_caret_line()
+ code_edit.begin_complex_operation()
+ code_edit.select(
+ line, get_line_length(), line + 1, code_edit.get_first_non_whitespace_column(line + 1)
+ )
+ code_edit.delete_selection()
+ code_edit.deselect()
+ code_edit.insert_text_at_caret(" ")
+ code_edit.end_complex_operation()
+
+
+## Centers the cursor on the screen
+func cmd_center_caret(_args: Dictionary):
+ code_edit.center_viewport_to_caret()
+
+
+## Replace the current character with [selected_char]
+## Args:
+## - "selected_char": String
+## as is processed in KeyMap::event_to_string()
+func cmd_replace(args: Dictionary):
+ var char: String = args.get("selected_char", "")
+ if char.begins_with(""):
+ char = "\n"
+ elif char.begins_with(""):
+ char = "\t"
+
+ code_edit.begin_complex_operation()
+ code_edit.delete_selection()
+ code_edit.insert_text_at_caret(char)
+ move_column(-1)
+ code_edit.end_complex_operation()
+
+
+## For now, all marks are global
+func cmd_mark(args: Dictionary):
+ if !args.has("selected_char"):
+ push_error("[GodotVIM] Error on cmd_mark(): No char selected")
+ return
+
+ if !globals.has("marks"):
+ globals.marks = {}
+ var m: String = args.selected_char
+ var unicode: int = m.unicode_at(0)
+ if (unicode < 65 or unicode > 90) and (unicode < 97 or unicode > 122):
+ # We use call_deferred because otherwise, the error gets overwritten at the end of _input()
+ status_bar.call_deferred(&"display_error", "Marks must be between a-z or A-Z")
+ return
+ globals.marks[m] = {
+ "file": globals.script_editor.get_current_script().resource_path, "pos": get_caret_pos()
+ }
+ status_bar.call_deferred(&"display_text", 'Mark "%s" set' % m)
+
+
+func cmd_jump_to_mark(args: Dictionary):
+ if !args.has("selected_char"):
+ push_error("[GodotVIM] Error on cmd_jump_to_mark(): No char selected")
+ return
+ if !globals.has("marks"):
+ globals.marks = {}
+
+ var m: String = args.selected_char
+ if !globals.marks.has(m):
+ status_bar.display_error('Mark "%s" not set' % m)
+ return
+ var mark: Dictionary = globals.marks[m]
+ globals.vim_plugin.edit_script(mark.file, mark.pos + Vector2i(0, 1))
+ code_edit.call_deferred(&"center_viewport_to_caret")
+
+
+#endregion ACTIONS
+
+#region OPERATIONS
+
+
+## Delete a selection
+## Corresponds to "d" in regular VIM
+func cmd_delete(args: Dictionary):
+ if args.get("line_wise", false):
+ var l0: int = code_edit.get_selection_from_line()
+ var l1: int = code_edit.get_selection_to_line()
+ code_edit.select(l0 - 1, get_line_length(l0 - 1), l1, get_line_length(l1))
+ call_deferred(&"move_line", +1)
+
+ code_edit.cut()
+
+ if mode != Mode.NORMAL:
+ set_mode(Mode.NORMAL)
+
+
+## Copies (yanks) a selection
+## Corresponds to "y" in regular VIM
+func cmd_yank(args: Dictionary):
+ if args.get("line_wise", false):
+ var l0: int = code_edit.get_selection_from_line()
+ var l1: int = code_edit.get_selection_to_line()
+ code_edit.select(l0 - 1, get_line_length(l0 - 1), l1, get_line_length(l1))
+
+ code_edit.copy()
+ code_edit.deselect()
+ if mode != Mode.NORMAL:
+ set_mode(Mode.NORMAL)
+
+
+## Changes a selection
+## Corresponds to "c" in regular VIM
+func cmd_change(args: Dictionary):
+ if args.get("line_wise", false):
+ var l0: int = code_edit.get_selection_from_line()
+ var l1: int = code_edit.get_selection_to_line()
+
+ code_edit.select(l0, code_edit.get_first_non_whitespace_column(l0), l1, get_line_length(l1))
+
+ code_edit.cut()
+ set_mode(Mode.INSERT)
+
+
+func cmd_paste(_args: Dictionary):
+ code_edit.begin_complex_operation()
+
+ if !is_mode_visual(mode):
+ if DisplayServer.clipboard_get().begins_with("\r\n"):
+ set_column(get_line_length())
+ else:
+ move_column(+1)
+ code_edit.deselect()
+
+ code_edit.paste()
+ move_column(-1)
+ code_edit.end_complex_operation()
+ set_mode(Mode.NORMAL)
+
+
+## Indents or unindents the selected line(s) by 1 level
+## Corresponds to >> or << in regular VIM
+## Args:
+## - (optional) "forward": whether to indent *in*. Defaults to false
+func cmd_indent(args: Dictionary):
+ if args.get("forward", false):
+ code_edit.indent_lines()
+ else:
+ code_edit.unindent_lines()
+ set_mode(Mode.NORMAL)
+
+
+## Toggles whether the selected line(s) are commented
+func cmd_comment(_args: Dictionary):
+ var l0: int = code_edit.get_selection_from_line()
+ var l1: int = code_edit.get_selection_to_line()
+ var do_comment: bool = !is_line_commented(mini(l0, l1))
+
+ code_edit.begin_complex_operation()
+ for line in range(mini(l0, l1), maxi(l0, l1) + 1):
+ set_line_commented(line, do_comment)
+ code_edit.end_complex_operation()
+
+ set_mode(Mode.NORMAL)
+
+
+## Sets the selected text to uppercase or lowercase
+## Args:
+## - "uppercase": bool (default: false)
+## Whether to toggle uppercase or lowercase
+func cmd_set_uppercase(args: Dictionary):
+ var text: String = code_edit.get_selected_text()
+ if args.get("uppercase", false):
+ code_edit.insert_text_at_caret(text.to_upper())
+ else:
+ code_edit.insert_text_at_caret(text.to_lower())
+
+ set_mode(Mode.NORMAL)
+
+
+## Toggles the case of the selectex text
+func cmd_toggle_uppercase(_args: Dictionary):
+ var text: String = code_edit.get_selected_text()
+ for i in text.length():
+ var char: String = text[i]
+ if is_uppercase(char):
+ text[i] = char.to_lower()
+ else:
+ text[i] = char.to_upper()
+ code_edit.insert_text_at_caret(text)
+
+ set_mode(Mode.NORMAL)
+
+
+#endregion OPERATIONS
+
+
+## Corresponds to 'o' in Visual mode in regular Vim
+func cmd_visual_jump_to_other_end(args: Dictionary):
+ if !is_mode_visual(mode):
+ push_warning(
+ "[GodotVim] Attempting to jump to other end of selection while not in VISUAL mode. Ignoring..."
+ )
+ return
+
+ var p: Vector2i = selection_from
+ selection_from = get_caret_pos()
+ set_caret_pos(p.y, p.x)
+#endregion COMMANDS
diff --git a/addons/godot_vim/dispatcher.gd b/addons/godot_vim/dispatcher.gd
new file mode 100644
index 0000000..a8c3a63
--- /dev/null
+++ b/addons/godot_vim/dispatcher.gd
@@ -0,0 +1,39 @@
+extends Object
+
+var handlers: Dictionary = {
+ "goto": preload("res://addons/godot_vim/commands/goto.gd"),
+ "find": preload("res://addons/godot_vim/commands/find.gd"),
+ "marks": preload("res://addons/godot_vim/commands/marks.gd"),
+ "delmarks": preload("res://addons/godot_vim/commands/delmarks.gd"),
+ "moveline": preload("res://addons/godot_vim/commands/moveline.gd"),
+ "movecolumn": preload("res://addons/godot_vim/commands/movecolumn.gd"),
+ "w": preload("res://addons/godot_vim/commands/w.gd"),
+ "wa": preload("res://addons/godot_vim/commands/wa.gd"),
+ # GodotVIM speficic commands:
+ "reload": preload("res://addons/godot_vim/commands/reload.gd"),
+ "remap": preload("res://addons/godot_vim/commands/remap.gd"),
+}
+
+var aliases: Dictionary = {"delm": ":delmarks"}
+
+var globals: Dictionary
+
+
+## Returns [enum @GlobalScope.Error]
+func dispatch(command: String, do_allow_aliases: bool = true) -> int:
+ var command_idx_end: int = command.find(" ", 1)
+ if command_idx_end == -1:
+ command_idx_end = command.length()
+ var handler_name: String = command.substr(1, command_idx_end - 1)
+
+ if do_allow_aliases and aliases.has(handler_name):
+ return dispatch(aliases[handler_name], false)
+
+ if not handlers.has(handler_name):
+ return ERR_DOES_NOT_EXIST
+
+ var handler = handlers.get(handler_name)
+ var handler_instance = handler.new()
+ var args: String = command.substr(command_idx_end, command.length())
+ handler_instance.execute(globals, args)
+ return OK
diff --git a/addons/godot_vim/hack_regular.ttf b/addons/godot_vim/hack_regular.ttf
new file mode 100644
index 0000000..92a90cb
Binary files /dev/null and b/addons/godot_vim/hack_regular.ttf differ
diff --git a/addons/godot_vim/hack_regular.ttf.import b/addons/godot_vim/hack_regular.ttf.import
new file mode 100644
index 0000000..dae8cc6
--- /dev/null
+++ b/addons/godot_vim/hack_regular.ttf.import
@@ -0,0 +1,34 @@
+[remap]
+
+importer="font_data_dynamic"
+type="FontFile"
+uid="uid://4q1f672e7ux2"
+path="res://.godot/imported/hack_regular.ttf-0fe21890026c2274f233cdc75cf86ba7.fontdata"
+
+[deps]
+
+source_file="res://addons/godot_vim/hack_regular.ttf"
+dest_files=["res://.godot/imported/hack_regular.ttf-0fe21890026c2274f233cdc75cf86ba7.fontdata"]
+
+[params]
+
+Rendering=null
+antialiasing=1
+generate_mipmaps=false
+disable_embedded_bitmaps=true
+multichannel_signed_distance_field=false
+msdf_pixel_range=8
+msdf_size=48
+allow_system_fallback=true
+force_autohinter=false
+hinting=1
+subpixel_positioning=1
+oversampling=0.0
+Fallbacks=null
+fallbacks=[]
+Compress=null
+compress=true
+preload=[]
+language_support={}
+script_support={}
+opentype_features={}
diff --git a/addons/godot_vim/key_map.gd b/addons/godot_vim/key_map.gd
new file mode 100644
index 0000000..79486e7
--- /dev/null
+++ b/addons/godot_vim/key_map.gd
@@ -0,0 +1,741 @@
+class_name KeyMap extends RefCounted
+## Hanldes input stream and key mapping
+##
+## You may also set your keybindings in the [method map] function
+
+
+## * SET YOUR KEYBINDINGS HERE *
+## Also see the "COMMANDS" section at the bottom of cursor.gd
+## E.g. the command for
+## KeyRemap.new(...) .motion("foo", { "bar": 1 })
+## is handled in Cursor::cmd_foo(args: Dictionary)
+## where `args` is `{ "type": "foo", "bar": 1 }`
+## Example:
+## [codeblock]
+## return [
+## # Move 5 characters to the right with "L"
+## KeyRemap.new([ "L" ])
+## .motion("move_by_chars", { "move_by": 5 }),
+##
+## # Let's remove "d" (the delete operator) and replace it with "q"
+## # You may additionally specify the type and context of the cmd to remove
+## # using .operator() (or .motion() or .action() etc...) and .with_context()
+## KeyRemap.new([ "d" ])
+## .remove(),
+## # "q" is now the new delete operator
+## KeyRemap.new([ "q" ])
+## .operator("delete"),
+##
+## # Delete this line along with the next two with "Z"
+## # .operator() and .motion() automatically merge together
+## KeyRemap.new([ "Z" ])
+## .operator("delete")
+## .motion("move_by_lines", { "move_by": 2, "line_wise": true }),
+##
+## # In Insert mode, return to Normal mode with "jk"
+## KeyRemap.new([ "j", "k" ])
+## .action("normal", { "backspaces": 1, "offset": 1 })
+## .with_context(Mode.INSERT),
+## ]
+## [/codeblock]
+static func map() -> Array[KeyRemap]:
+ # Example:
+ return [
+ # In Insert mode, return to Normal mode with "jk"
+ # KeyRemap.new([ "j", "k" ])
+ # .action("normal", { "backspaces": 1, "offset": 0 })
+ # .with_context(Mode.INSERT),
+ # Make "/" search in case insensitive mode
+ # KeyRemap.new([ "/" ])
+ # .action("command", { "command": "/(?i)" })
+ # .replace(),
+ # In Insert mode, return to Normal mode with "Ctrl-["
+ # KeyRemap.new([ "" ])
+ # .action("normal")
+ # .with_context(Mode.INSERT),
+ ]
+
+
+const Constants = preload("res://addons/godot_vim/constants.gd")
+const Mode = Constants.Mode
+
+const INSERT_MODE_TIMEOUT_MS: int = 700
+
+enum {
+ ## Moves the cursor. Can be used in tandem with Operator
+ Motion,
+ ## Operators (like delete, change, yank) work on selections
+ ## In Normal mode, they need a Motion or another Operator bound to them (e.g. dj, yy)
+ Operator,
+ ## Operator but with a motion already bound to it
+ ## Can only be executed in Normal mode
+ OperatorMotion,
+ ## A single action (e.g. i, o, v, J, u)
+ ## Cannot be executed in Visual mode unless specified with "context": Mode.VISUAL
+ Action,
+ Incomplete, ## Incomplete command
+ NotFound, ## Command not found
+}
+
+#region key_map
+
+# Also see the "COMMANDS" section at the bottom of cursor.gd
+# Command for { "type": "foo", ... } is handled in Cursor::cmd_foo(args: Dictionary)
+# where `args` is ^^^^^ this Dict ^^^^^^
+var key_map: Array[Dictionary] = [
+ # MOTIONS
+ {"keys": ["h"], "type": Motion, "motion": {"type": "move_by_chars", "move_by": -1}},
+ {"keys": ["l"], "type": Motion, "motion": {"type": "move_by_chars", "move_by": 1}},
+ {
+ "keys": ["j"],
+ "type": Motion,
+ "motion": {"type": "move_by_lines", "move_by": 1, "line_wise": true}
+ },
+ {
+ "keys": ["k"],
+ "type": Motion,
+ "motion": {"type": "move_by_lines", "move_by": -1, "line_wise": true}
+ },
+ # About motions: the argument `inclusive` is used with Operators (see execute_operator_motion())
+ {
+ "keys": ["w"],
+ "type": Motion,
+ "motion": {"type": "move_by_word", "forward": true, "word_end": false}
+ },
+ {
+ "keys": ["e"],
+ "type": Motion,
+ "motion": {"type": "move_by_word", "forward": true, "word_end": true, "inclusive": true}
+ },
+ {
+ "keys": ["b"],
+ "type": Motion,
+ "motion": {"type": "move_by_word", "forward": false, "word_end": false}
+ },
+ {
+ "keys": ["g", "e"],
+ "type": Motion,
+ "motion": {"type": "move_by_word", "forward": false, "word_end": true}
+ },
+ {
+ "keys": ["W"],
+ "type": Motion,
+ "motion": {"type": "move_by_word", "forward": true, "word_end": false, "big_word": true}
+ },
+ {
+ "keys": ["E"],
+ "type": Motion,
+ "motion":
+ {
+ "type": "move_by_word",
+ "forward": true,
+ "word_end": true,
+ "big_word": true,
+ "inclusive": true
+ }
+ },
+ {
+ "keys": ["B"],
+ "type": Motion,
+ "motion": {"type": "move_by_word", "forward": false, "word_end": false, "big_word": true}
+ },
+ {
+ "keys": ["g", "E"],
+ "type": Motion,
+ "motion": {"type": "move_by_word", "forward": false, "word_end": true, "big_word": true}
+ },
+ # Find & search
+ {
+ "keys": ["f", "{char}"],
+ "type": Motion,
+ "motion": {"type": "find_in_line", "forward": true, "inclusive": true}
+ },
+ {
+ "keys": ["t", "{char}"],
+ "type": Motion,
+ "motion": {"type": "find_in_line", "forward": true, "stop_before": true, "inclusive": true}
+ },
+ {"keys": ["F", "{char}"], "type": Motion, "motion": {"type": "find_in_line", "forward": false}},
+ {
+ "keys": ["T", "{char}"],
+ "type": Motion,
+ "motion": {"type": "find_in_line", "forward": false, "stop_before": true}
+ },
+ {"keys": [";"], "type": Motion, "motion": {"type": "find_in_line_again", "invert": false}},
+ {"keys": [","], "type": Motion, "motion": {"type": "find_in_line_again", "invert": true}},
+ {"keys": ["n"], "type": Motion, "motion": {"type": "find_again", "forward": true}},
+ {"keys": ["N"], "type": Motion, "motion": {"type": "find_again", "forward": false}},
+ {"keys": ["0"], "type": Motion, "motion": {"type": "move_to_bol"}},
+ {"keys": ["$"], "type": Motion, "motion": {"type": "move_to_eol"}},
+ {"keys": ["^"], "type": Motion, "motion": {"type": "move_to_first_non_whitespace_char"}},
+ {
+ "keys": ["{"],
+ "type": Motion,
+ "motion": {"type": "move_by_paragraph", "forward": false, "line_wise": true}
+ },
+ {
+ "keys": ["}"],
+ "type": Motion,
+ "motion": {"type": "move_by_paragraph", "forward": true, "line_wise": true}
+ },
+ {
+ "keys": ["[", "["],
+ "type": Motion,
+ "motion": {"type": "move_by_section", "forward": false, "line_wise": true}
+ },
+ {
+ "keys": ["]", "]"],
+ "type": Motion,
+ "motion": {"type": "move_by_section", "forward": true, "line_wise": true}
+ },
+ {"keys": ["g", "g"], "type": Motion, "motion": {"type": "move_to_bof", "line_wise": true}},
+ {"keys": ["G"], "type": Motion, "motion": {"type": "move_to_eof", "line_wise": true}},
+ {"keys": ["g", "m"], "type": Motion, "motion": {"type": "move_to_center_of_line"}},
+ {
+ "keys": [""],
+ "type": Motion,
+ "motion": {"type": "move_by_screen", "percentage": -0.5, "line_wise": true}
+ },
+ {
+ "keys": [""],
+ "type": Motion,
+ "motion": {"type": "move_by_screen", "percentage": 0.5, "line_wise": true}
+ },
+ {"keys": ["%"], "type": Motion, "motion": {"type": "jump_to_next_brace_pair"}},
+ # TEXT OBJECTS
+ {
+ "keys": ["a", "w"],
+ "type": Motion,
+ "motion": {"type": "text_object_word", "around": true, "inclusive": true}
+ },
+ {
+ "keys": ["a", "W"],
+ "type": Motion,
+ "motion": {"type": "text_object_word", "around": true, "inclusive": true}
+ },
+ {"keys": ["i", "w"], "type": Motion, "motion": {"type": "text_object_word", "inclusive": true}},
+ {
+ "keys": ["i", "W"],
+ "type": Motion,
+ "motion": {"type": "text_object_word", "big_word": true, "inclusive": true}
+ },
+ {
+ "keys": ["i", "p"],
+ "type": Motion,
+ "motion": {"type": "text_object_paragraph", "line_wise": true}
+ },
+ {
+ "keys": ["a", "p"],
+ "type": Motion,
+ "motion": {"type": "text_object_paragraph", "around": true, "line_wise": true}
+ },
+ {
+ "keys": ["i", '"'],
+ "type": Motion,
+ "motion": {"type": "text_object", "object": '"', "inclusive": true, "inline": true}
+ },
+ {
+ "keys": ["a", '"'],
+ "type": Motion,
+ "motion":
+ {"type": "text_object", "object": '"', "around": true, "inclusive": true, "inline": true}
+ },
+ {
+ "keys": ["i", "'"],
+ "type": Motion,
+ "motion": {"type": "text_object", "object": "'", "inclusive": true, "inline": true}
+ },
+ {
+ "keys": ["a", "'"],
+ "type": Motion,
+ "motion":
+ {"type": "text_object", "object": "'", "around": true, "inclusive": true, "inline": true}
+ },
+ {
+ "keys": ["i", "`"],
+ "type": Motion,
+ "motion": {"type": "text_object", "object": "`", "inclusive": true, "inline": true}
+ },
+ {
+ "keys": ["a", "`"],
+ "type": Motion,
+ "motion":
+ {"type": "text_object", "object": "`", "around": true, "inclusive": true, "inline": true}
+ },
+ # "i" + any of "(", ")", or "b"
+ {
+ "keys": ["i", ["(", ")", "b"]],
+ "type": Motion,
+ "motion": {"type": "text_object", "object": "(", "inclusive": true}
+ },
+ {
+ "keys": ["a", ["(", ")", "b"]],
+ "type": Motion,
+ "motion": {"type": "text_object", "object": "(", "around": true, "inclusive": true}
+ },
+ {
+ "keys": ["i", ["[", "]"]],
+ "type": Motion,
+ "motion": {"type": "text_object", "object": "[", "inclusive": true}
+ },
+ {
+ "keys": ["a", ["[", "]"]],
+ "type": Motion,
+ "motion": {"type": "text_object", "object": "[", "around": true, "inclusive": true}
+ },
+ {
+ "keys": ["i", ["{", "}", "B"]],
+ "type": Motion,
+ "motion": {"type": "text_object", "object": "{", "inclusive": true}
+ },
+ {
+ "keys": ["a", ["{", "}", "B"]],
+ "type": Motion,
+ "motion": {"type": "text_object", "object": "{", "around": true, "inclusive": true}
+ },
+ # OPERATORS
+ {"keys": ["d"], "type": Operator, "operator": {"type": "delete"}},
+ {
+ "keys": ["D"],
+ "type": OperatorMotion,
+ "operator": {"type": "delete"},
+ "motion": {"type": "move_to_eol"}
+ },
+ {
+ "keys": ["x"],
+ "type": OperatorMotion,
+ "operator": {"type": "delete"},
+ "motion": {"type": "move_by_chars", "move_by": 1}
+ },
+ {"keys": ["x"], "type": Operator, "context": Mode.VISUAL, "operator": {"type": "delete"}},
+ {"keys": ["y"], "type": Operator, "operator": {"type": "yank"}},
+ {
+ "keys": ["Y"],
+ "type": OperatorMotion,
+ "operator": {"type": "yank", "line_wise": true}, # No motion. Same as yy
+ },
+ {"keys": ["c"], "type": Operator, "operator": {"type": "change"}},
+ {
+ "keys": ["C"],
+ "type": OperatorMotion,
+ "operator": {"type": "change"},
+ "motion": {"type": "move_to_eol"}
+ },
+ {
+ "keys": ["s"],
+ "type": OperatorMotion,
+ "operator": {"type": "change"},
+ "motion": {"type": "move_by_chars", "move_by": 1}
+ },
+ {"keys": ["s"], "type": Operator, "context": Mode.VISUAL, "operator": {"type": "change"}},
+ {
+ "keys": ["p"],
+ "type": OperatorMotion,
+ "operator": {"type": "paste"},
+ "motion": {"type": "move_by_chars", "move_by": 1}
+ },
+ {"keys": ["p"], "type": Operator, "context": Mode.VISUAL, "operator": {"type": "paste"}},
+ {"keys": ["p"], "type": Operator, "context": Mode.VISUAL_LINE, "operator": {"type": "paste"}},
+ {"keys": [">"], "type": Operator, "operator": {"type": "indent", "forward": true}},
+ {"keys": ["<"], "type": Operator, "operator": {"type": "indent", "forward": false}},
+ {
+ "keys": ["g", "c", "c"],
+ "type": OperatorMotion,
+ "operator": {"type": "comment"},
+ "motion": {"type": "move_by_chars", "move_by": 1}
+ },
+ {"keys": ["g", "c"], "type": Operator, "operator": {"type": "comment"}},
+ {
+ "keys": ["~"],
+ "type": OperatorMotion,
+ "operator": {"type": "toggle_uppercase"},
+ "motion": {"type": "move_by_chars", "move_by": 1}
+ },
+ {
+ "keys": ["~"],
+ "type": Operator,
+ "context": Mode.VISUAL,
+ "operator": {"type": "toggle_uppercase"}
+ },
+ {
+ "keys": ["u"],
+ "type": Operator,
+ "context": Mode.VISUAL,
+ "operator": {"type": "set_uppercase", "uppercase": false}
+ },
+ {
+ "keys": ["U"],
+ "type": Operator,
+ "context": Mode.VISUAL,
+ "operator": {"type": "set_uppercase", "uppercase": true}
+ },
+ {
+ "keys": ["V"],
+ "type": Operator,
+ "context": Mode.VISUAL,
+ "operator": {"type": "visual", "line_wise": true}
+ },
+ {
+ "keys": ["v"],
+ "type": Operator,
+ "context": Mode.VISUAL_LINE,
+ "operator": {"type": "visual", "line_wise": false}
+ },
+ # ACTIONS
+ {"keys": ["i"], "type": Action, "action": {"type": "insert"}},
+ {"keys": ["a"], "type": Action, "action": {"type": "insert", "offset": "after"}},
+ {"keys": ["I"], "type": Action, "action": {"type": "insert", "offset": "bol"}},
+ {"keys": ["A"], "type": Action, "action": {"type": "insert", "offset": "eol"}},
+ {"keys": ["o"], "type": Action, "action": {"type": "insert", "offset": "new_line_below"}},
+ {"keys": ["O"], "type": Action, "action": {"type": "insert", "offset": "new_line_above"}},
+ {"keys": ["v"], "type": Action, "action": {"type": "visual"}},
+ {"keys": ["V"], "type": Action, "action": {"type": "visual", "line_wise": true}},
+ {"keys": ["u"], "type": Action, "action": {"type": "undo"}},
+ {"keys": [""], "type": Action, "action": {"type": "redo"}},
+ {"keys": ["r", "{char}"], "type": Action, "action": {"type": "replace"}},
+ {"keys": [":"], "type": Action, "action": {"type": "command"}},
+ {"keys": ["/"], "type": Action, "action": {"type": "command", "command": "/"}},
+ {"keys": ["J"], "type": Action, "action": {"type": "join"}},
+ {"keys": ["z", "z"], "type": Action, "action": {"type": "center_caret"}},
+ {"keys": ["m", "{char}"], "type": Action, "action": {"type": "mark"}},
+ {"keys": ["`", "{char}"], "type": Action, "action": {"type": "jump_to_mark"}},
+ # MISCELLANEOUS
+ {
+ "keys": ["o"],
+ "type": Operator,
+ "context": Mode.VISUAL,
+ "operator": {"type": "visual_jump_to_other_end"}
+ },
+ {
+ "keys": ["o"],
+ "type": Operator,
+ "context": Mode.VISUAL_LINE,
+ "operator": {"type": "visual_jump_to_other_end"}
+ },
+]
+
+#endregion key_map
+
+# Keys we won't handle
+const BLACKLIST: Array[String] = [
+ "", # Save
+ "", # Bookmark
+]
+
+enum KeyMatch {
+ None = 0, # Keys don't match
+ Partial = 1, # Keys match partially
+ Full = 2, # Keys match totally
+}
+
+var input_stream: Array[String] = []
+var cursor: Control
+var last_insert_mode_input_ms: int = 0
+
+
+func _init(cursor_: Control):
+ cursor = cursor_
+ apply_remaps(KeyMap.map())
+
+
+## Returns: Dictionary with the found command: { "type": Motion or Operator or OperatorMotion or Action or Incomplete or NotFound, ... }
+## Warning: the returned Dict can be empty in if the event wasn't processed
+func register_event(event: InputEventKey, with_context: Mode) -> Dictionary:
+ # Stringify event
+ var ch: String = event_to_string(event)
+ if ch.is_empty():
+ return {} # Invalid
+ if BLACKLIST.has(ch):
+ return {}
+
+ # Handle Insert mode timeout
+ if with_context == Mode.INSERT:
+ if handle_insert_mode_timeout():
+ clear()
+ return {}
+
+ # Process input stream
+ # print("[KeyMap::register_event()] ch = ", ch) # DEBUG
+ input_stream.append(ch)
+ var cmd: Dictionary = parse_keys(input_stream, with_context)
+ if !is_cmd_valid(cmd):
+ return {"type": NotFound}
+
+ execute(cmd)
+ return cmd
+
+
+func parse_keys(keys: Array[String], with_context: Mode) -> Dictionary:
+ var blacklist: Array = get_blacklist_types_in_context(with_context)
+ var cmd: Dictionary = find_cmd(keys, with_context, blacklist)
+ if cmd.is_empty() or cmd.type == NotFound:
+ call_deferred(&"clear")
+ return cmd
+ if cmd.type == Incomplete:
+ # print(cmd)
+ return cmd
+
+ # Execute the operation as-is if in VISUAL mode
+ # If in NORMAL mode, await further input
+ if cmd.type == Operator and with_context == Mode.NORMAL:
+ var op_args: Array[String] = keys.slice(cmd.keys.size()) # Get the rest of keys for motion
+ if op_args.is_empty(): # Incomplete; await further input
+ return {"type": Incomplete}
+
+ var next: Dictionary = find_cmd(op_args, with_context, [Action, OperatorMotion])
+
+ if next.is_empty() or next.type == NotFound: # Invalid sequence
+ call_deferred(&"clear")
+ return {"type": NotFound}
+ elif next.type == Incomplete:
+ return {"type": Incomplete}
+
+ cmd.modifier = next
+
+ call_deferred(&"clear")
+ return cmd
+
+
+## The returned cmd will always have a 'type' key
+# TODO use bitmask instead of Array?
+func find_cmd(keys: Array[String], with_context: Mode, blacklist: Array = []) -> Dictionary:
+ var partial: bool = false # In case none were found
+ var is_visual: bool = with_context == Mode.VISUAL or with_context == Mode.VISUAL_LINE
+
+ for cmd in key_map:
+ # FILTERS
+ # Don't allow anything in Insert mode unless specified
+ if with_context == Mode.INSERT and cmd.get("context", -1) != Mode.INSERT:
+ continue
+
+ if blacklist.has(cmd.type):
+ continue
+
+ # Skip if contexts don't match
+ if cmd.has("context") and with_context != cmd.context:
+ continue
+
+ # CHECK KEYS
+ var m: KeyMatch = match_keys(cmd.keys, keys)
+ partial = partial or m == KeyMatch.Partial # Set/keep partial = true if it was a partial match
+
+ if m != KeyMatch.Full:
+ continue
+
+ var cmd_mut: Dictionary = cmd.duplicate(true) # 'mut' ('mutable') because key_map is read-only
+ # Keep track of selected character, which will later be copied into the fucntion call for the command
+ # (See execute() where we check if cmd.has('selected_char'))
+ if cmd.keys[-1] is String and cmd.keys[-1] == "{char}":
+ cmd_mut.selected_char = keys.back()
+ return cmd_mut
+
+ return {"type": Incomplete if partial else NotFound}
+
+
+# TODO use bitmask instead of Array?
+func get_blacklist_types_in_context(context: Mode) -> Array:
+ match context:
+ Mode.VISUAL, Mode.VISUAL_LINE:
+ return [OperatorMotion, Action]
+ _:
+ return []
+
+
+func execute_operator_motion(cmd: Dictionary):
+ if cmd.has("motion"):
+ if cmd.has("selected_char"):
+ cmd.motion.selected_char = cmd.selected_char
+ operator_motion(cmd.operator, cmd.motion)
+ else:
+ call_cmd(cmd.operator)
+
+
+func execute_operator(cmd: Dictionary):
+ # print("[KeyMay::execute()] op: ", cmd) # DEBUG
+ if !cmd.has("modifier"): # Execute as-is
+ call_cmd(cmd.operator)
+ return
+
+ var mod: Dictionary = cmd.modifier
+ # Execute with motion
+ if mod.type == Motion:
+ if mod.has("selected_char"):
+ mod.motion.selected_char = mod.selected_char
+ operator_motion(cmd.operator, mod.motion)
+
+ # Execute with `line_wise = true` if repeating operations (e.g. dd, yy)
+ elif mod.type == Operator and mod.operator.type == cmd.operator.type:
+ var op_cmd: Dictionary = cmd.operator.duplicate()
+ op_cmd.line_wise = true
+ call_cmd(op_cmd)
+
+
+func execute_action(cmd: Dictionary):
+ if cmd.has("selected_char"):
+ cmd.action.selected_char = cmd.selected_char
+ call_cmd(cmd.action)
+
+
+func execute_motion(cmd: Dictionary):
+ if cmd.has("selected_char"):
+ cmd.motion.selected_char = cmd.selected_char
+ var pos = call_cmd(cmd.motion) # Vector2i for normal motion, or [Vector2i, Vector2i] for text object
+
+ if pos is Vector2i:
+ cursor.set_caret_pos(pos.y, pos.x)
+ elif pos is Array:
+ assert(pos.size() == 2)
+ # print("[execute_motion() -> text obj] pos = ", pos)
+ cursor.select(pos[0].y, pos[0].x, pos[1].y, pos[1].x)
+
+
+func execute(cmd: Dictionary):
+ if !is_cmd_valid(cmd):
+ return
+
+ match cmd.type:
+ Motion:
+ execute_motion(cmd)
+ OperatorMotion:
+ execute_operator_motion(cmd)
+ Operator:
+ execute_operator(cmd)
+ Action:
+ execute_action(cmd)
+ _:
+ push_error("[KeyMap::execute()] Unknown command type: %s" % cmd.type)
+
+
+func operator_motion(operator: Dictionary, motion: Dictionary):
+ # print("[KeyMay::execute_operator_motion()] op = ", operator, ", motion = ", motion) # DEBUG
+
+ # Execute motion before operation
+ var p = call_cmd(motion) # Vector2i for normal motion, or [Vector2i, Vector2i] for text object
+ if p is Vector2i:
+ var p0: Vector2i = cursor.get_caret_pos()
+ if motion.get("inclusive", false):
+ p.x += 1
+ cursor.code_edit.select(p0.y, p0.x, p.y, p.x)
+ elif p is Array:
+ assert(p.size() == 2)
+ if motion.get("inclusive", false):
+ p[1].x += 1
+ cursor.code_edit.select(p[0].y, p[0].x, p[1].y, p[1].x)
+
+ # Add line_wise flag if line wise motion
+ var op: Dictionary = operator.duplicate()
+ op.line_wise = motion.get("line_wise", false)
+ call_cmd(op)
+
+
+## Unsafe: does not check if the function exists
+func call_cmd(cmd: Dictionary) -> Variant:
+ var func_name: StringName = StringName("cmd_" + cmd.type)
+ return cursor.call(func_name, cmd)
+
+
+static func is_cmd_valid(cmd: Dictionary):
+ return !cmd.is_empty() and cmd.type != Incomplete and cmd.type != NotFound
+
+
+static func event_to_string(event: InputEventKey) -> String:
+ # Special chars
+ if event.keycode == KEY_ENTER:
+ return ""
+ if event.keycode == KEY_TAB:
+ return ""
+ if event.keycode == KEY_ESCAPE:
+ return ""
+
+ # Ctrl + key
+ if event.is_command_or_control_pressed():
+ if !OS.is_keycode_unicode(event.keycode):
+ return ""
+ var c: String = char(event.keycode)
+ return "" % [c if event.shift_pressed else c.to_lower()]
+
+ # You're not special.
+ return char(event.unicode)
+
+
+## Matches single command keys
+## expected_keys: Array[key: String] or Array[any_of_these_keys: Array[key: String]]
+static func match_keys(expected_keys: Array, input_keys: Array) -> KeyMatch:
+ var in_size: int = input_keys.size()
+ var ex_size: int = expected_keys.size()
+
+ if expected_keys[-1] is String and expected_keys[-1] == "{char}":
+ # If everything + {char} matches
+ if _do_keys_match(input_keys.slice(0, -1), expected_keys.slice(0, -1)):
+ return KeyMatch.Full
+
+ # If everything up until {char} matches
+ elif _do_keys_match(input_keys, expected_keys.slice(0, in_size)):
+ return KeyMatch.Partial
+
+ else:
+ # Check for full match
+ if _do_keys_match(input_keys, expected_keys):
+ return KeyMatch.Full
+ # Check for incomplete command (e.g. "ge", "gcc")
+ elif _do_keys_match(input_keys, expected_keys.slice(0, in_size)):
+ return KeyMatch.Partial
+ # Cases with operators like "dj", "ce"
+ elif _do_keys_match(input_keys.slice(0, ex_size), expected_keys) and in_size > ex_size:
+ return KeyMatch.Full
+
+ return KeyMatch.None
+
+
+## input_keys: Array[key: String]
+## expected_keys: Array[key: String] or Array[any_of_these_keys: Array[key: String]]
+static func _do_keys_match(input_keys: Array, match_keys: Array) -> bool:
+ if match_keys.size() != input_keys.size():
+ return false
+
+ for i in input_keys.size():
+ var key: String = input_keys[i]
+ if match_keys[i] is String:
+ if !match_keys[i] == key:
+ return false
+ elif match_keys[i] is Array:
+ if !match_keys[i].has(key):
+ return false
+ else:
+ push_error("expected String or Array[String], found ", match_keys[i])
+ return false
+
+ return true
+
+
+## Clears the input stream
+func clear():
+ input_stream = []
+
+
+func get_input_stream_as_string() -> String:
+ return "".join(PackedStringArray(input_stream))
+
+
+## Returns whether the Insert mode input has timed out, in which case we
+## don't want to process it
+func handle_insert_mode_timeout() -> bool:
+ var current_tick_ms: int = Time.get_ticks_msec()
+
+ if input_stream.is_empty():
+ last_insert_mode_input_ms = current_tick_ms
+ return false
+
+ if current_tick_ms - last_insert_mode_input_ms > INSERT_MODE_TIMEOUT_MS:
+ last_insert_mode_input_ms = current_tick_ms
+ return true
+ last_insert_mode_input_ms = current_tick_ms
+ return false
+
+
+func apply_remaps(map: Array[KeyRemap]):
+ if map.is_empty():
+ return
+ print("[Godot VIM] Applying keybind remaps...")
+ for remap in map:
+ remap.apply(key_map)
diff --git a/addons/godot_vim/plugin.cfg b/addons/godot_vim/plugin.cfg
new file mode 100644
index 0000000..5726e85
--- /dev/null
+++ b/addons/godot_vim/plugin.cfg
@@ -0,0 +1,7 @@
+[plugin]
+
+name="GodotVim"
+description=""
+author="Bernardo Bruning"
+version="0.1"
+script="plugin.gd"
diff --git a/addons/godot_vim/plugin.gd b/addons/godot_vim/plugin.gd
new file mode 100644
index 0000000..9f44115
--- /dev/null
+++ b/addons/godot_vim/plugin.gd
@@ -0,0 +1,354 @@
+@tool
+extends EditorPlugin
+
+const StatusBar = preload("res://addons/godot_vim/status_bar.gd")
+const CommandLine = preload("res://addons/godot_vim/command_line.gd")
+const Cursor = preload("res://addons/godot_vim/cursor.gd")
+const Dispatcher = preload("res://addons/godot_vim/dispatcher.gd")
+
+const Constants = preload("res://addons/godot_vim/constants.gd")
+const DIGITS = Constants.DIGITS
+const LANGUAGE = Constants.Language
+
+var cursor: Cursor
+var key_map: KeyMap
+var command_line: CommandLine
+var status_bar: StatusBar
+var globals: Dictionary = {}
+var dispatcher: Dispatcher
+
+
+func _enter_tree():
+ EditorInterface.get_script_editor().connect("editor_script_changed", _on_script_changed)
+
+ var shader_tabcontainer = get_shader_tabcontainer() as TabContainer
+ if shader_tabcontainer != null:
+ shader_tabcontainer.tab_changed.connect(_on_shader_tab_changed)
+ shader_tabcontainer.visibility_changed.connect(_on_shader_tab_visibility_changed)
+ else:
+ push_error(
+ "[Godot VIM] Failed to get shader editor's TabContainer. Vim will be disabled in the shader editor"
+ )
+
+ globals = {}
+ initialize(true)
+
+
+func initialize(forced: bool = false):
+ _load(forced)
+
+ print("[Godot VIM] Initialized.")
+ print(" If you wish to set keybindings, please run :remap in the command line")
+
+
+func _on_script_changed(script: Script):
+ if !script:
+ return
+
+ mark_recent_file(script.resource_path)
+
+ _load()
+
+
+func _on_shader_tab_changed(_tab: int):
+ call_deferred(&"_load")
+
+
+func _on_shader_tab_visibility_changed():
+ call_deferred(&"_load")
+
+
+func mark_recent_file(path: String):
+ if !globals.has("marks"):
+ globals.marks = {}
+ var marks: Dictionary = globals.marks
+
+ # Check if path is already in the recent files (stored in start_index)
+ # This is to avoid flooding the recent files list with the same files
+ var start_index: int = 0
+ while start_index <= 9:
+ var m: String = str(start_index)
+ if !marks.has(m) or marks[m].file == path: # Found
+ break
+ start_index += 1
+
+ # Shift all files from start_index down one
+ for i in range(start_index, -1, -1):
+ var m: String = str(i)
+ var prev_m: String = str(i - 1)
+ if !marks.has(prev_m):
+ continue
+ marks[m] = marks[prev_m]
+
+ # Mark "-1" won't be accessible to the user
+ # It's just the current file, and will be indexed next time the
+ # loop above ^^^ is called
+ marks["-1"] = {"file": path, "pos": Vector2i(-1, 0)}
+
+
+func edit_script(path: String, pos: Vector2i):
+ var script = load(path)
+ if script == null:
+ status_bar.display_error('Could not open file "%s"' % path)
+ return ""
+ EditorInterface.edit_script(script, pos.y, pos.x)
+
+
+#region LOAD
+
+
+func _init_cursor(code_edit: CodeEdit, language: LANGUAGE):
+ if cursor != null:
+ cursor.queue_free()
+
+ cursor = Cursor.new()
+ code_edit.select(
+ code_edit.get_caret_line(),
+ code_edit.get_caret_column(),
+ code_edit.get_caret_line(),
+ code_edit.get_caret_column() + 1
+ )
+ cursor.code_edit = code_edit
+ cursor.language = language
+ cursor.globals = globals
+
+
+func _init_command_line(code_edit: CodeEdit):
+ if command_line != null:
+ command_line.queue_free()
+ command_line = CommandLine.new()
+
+ command_line.code_edit = code_edit
+ cursor.command_line = command_line
+ command_line.cursor = cursor
+ command_line.globals = globals
+ command_line.hide()
+
+
+func _init_status_bar():
+ if status_bar != null:
+ status_bar.queue_free()
+ status_bar = StatusBar.new()
+ cursor.status_bar = status_bar
+ command_line.status_bar = status_bar
+
+
+func _load(forced: bool = false):
+ if globals == null:
+ globals = {}
+
+ var result: Dictionary = find_code_edit()
+ if result.is_empty():
+ return
+ var code_edit: CodeEdit = result.code_edit
+ var language: LANGUAGE = result.language
+
+ _init_cursor(code_edit, language)
+ _init_command_line(code_edit)
+ _init_status_bar()
+
+ # KeyMap
+ if key_map == null or forced:
+ key_map = KeyMap.new(cursor)
+ else:
+ key_map.cursor = cursor
+ cursor.key_map = key_map
+
+ var script_editor = EditorInterface.get_script_editor()
+ if script_editor == null:
+ return
+ var script_editor_base = script_editor.get_current_editor()
+ if script_editor_base == null:
+ return
+
+ globals.command_line = command_line
+ globals.status_bar = status_bar
+ globals.code_edit = code_edit
+ globals.cursor = cursor
+ globals.script_editor = script_editor
+ globals.vim_plugin = self
+ globals.key_map = key_map
+
+ dispatcher = Dispatcher.new()
+ dispatcher.globals = globals
+
+ # Add nodes
+ if language != LANGUAGE.SHADER:
+ script_editor_base.add_child(cursor)
+ script_editor_base.add_child(status_bar)
+ script_editor_base.add_child(command_line)
+ return
+
+ # Get shader editor VBoxContainer
+ var shaders_container = code_edit
+ for i in 3:
+ shaders_container = shaders_container.get_parent()
+ if shaders_container == null:
+ # We do not print an error here because for this to fail,
+ # get_shader_code_edit() (through find_code_edit()) must have
+ # already failed
+ return
+
+ shaders_container.add_child(cursor)
+ shaders_container.add_child(status_bar)
+ shaders_container.add_child(command_line)
+
+
+#endregion LOAD
+
+
+func dispatch(command: String):
+ return dispatcher.dispatch(command)
+
+
+## Finds whatever CodeEdit is open
+func find_code_edit() -> Dictionary:
+ var code_edit: CodeEdit = get_shader_code_edit()
+ var language: LANGUAGE = LANGUAGE.SHADER
+ # Shader panel not open; normal gdscript code edit
+ if code_edit == null:
+ code_edit = get_regular_code_edit()
+ language = LANGUAGE.GDSCRIPT
+ if code_edit == null:
+ return {}
+
+ return {
+ "code_edit": code_edit,
+ "language": language,
+ }
+
+
+## Gets the regular GDScript CodeEdit
+func get_regular_code_edit():
+ var editor = EditorInterface.get_script_editor().get_current_editor()
+ return _select(editor, ["VSplitContainer", "CodeTextEditor", "CodeEdit"])
+
+
+# FIXME Handle cases where the shader editor is its own floating window
+## Gets the shader editor's CodeEdit
+## Returns Option (aka CodeEdit or null)
+func get_shader_code_edit():
+ var container = get_shader_tabcontainer()
+ if container == null:
+ push_error(
+ "[Godot VIM] Failed to get shader editor's TabContainer. Vim will be disabled in the shader editor"
+ )
+ return null
+
+ # Panel not open
+ if !container.is_visible_in_tree():
+ return null
+
+ var editors = container.get_children(false)
+ for tse in editors:
+ if !tse.visible: # Not open
+ continue
+
+ var code_edit = _select(
+ tse, ["VBoxContainer", "VSplitContainer", "ShaderTextEditor", "CodeEdit"]
+ )
+
+ if code_edit == null:
+ push_error(
+ "[Godot Vim] Failed to get shader editor's CodeEdit. Vim will be disabled in the shader editor"
+ )
+ return null
+
+ return code_edit
+
+
+## Returns Option (aka either TabContainer or null if it fails)
+func get_shader_tabcontainer():
+ # Get the VSplitContainer containing the script editor and bottom panels
+ var container = EditorInterface.get_script_editor()
+ for i in 6:
+ container = container.get_parent()
+ if container == null:
+ # We don't print an error here, let us handle this exception elsewhere
+ return null
+
+ # Get code edit
+ container = _select(
+ container,
+ ["PanelContainer", "VBoxContainer", "WindowWrapper", "HSplitContainer", "TabContainer"]
+ )
+ return container
+
+
+func _select(obj: Node, types: Array[String]):
+ if not obj:
+ return null
+ for type in types:
+ for child in obj.get_children():
+ if child.is_class(type):
+ obj = child
+ continue
+ return obj
+
+
+func _exit_tree():
+ if cursor != null:
+ cursor.queue_free()
+ if command_line != null:
+ command_line.queue_free()
+ if status_bar != null:
+ status_bar.queue_free()
+
+
+# -------------------------------------------------------------
+# ** UTIL **
+# -------------------------------------------------------------
+
+
+func search_regex(text_edit: TextEdit, pattern: String, from_pos: Vector2i) -> RegExMatch:
+ var regex: RegEx = RegEx.new()
+ var err: int = regex.compile(pattern)
+ var idx: int = pos_to_idx(text_edit, from_pos)
+ var res: RegExMatch = regex.search(text_edit.text, idx)
+ if res == null:
+ return regex.search(text_edit.text, 0)
+ return res
+
+
+func search_regex_backwards(text_edit: TextEdit, pattern: String, from_pos: Vector2i) -> RegExMatch:
+ var regex: RegEx = RegEx.new()
+ var err: int = regex.compile(pattern)
+ var idx: int = pos_to_idx(text_edit, from_pos)
+ # We use pop_back() so it doesn't print an error
+ var res: RegExMatch = regex.search_all(text_edit.text, 0, idx).pop_back()
+ if res == null:
+ return regex.search_all(text_edit.text).pop_back()
+ return res
+
+
+func pos_to_idx(text_edit: TextEdit, pos: Vector2i) -> int:
+ text_edit.select(0, 0, pos.y, pos.x)
+ var len: int = text_edit.get_selected_text().length()
+ text_edit.deselect()
+ return len
+
+
+func idx_to_pos(text_edit: TextEdit, idx: int) -> Vector2i:
+ var line: int = text_edit.text.count("\n", 0, idx)
+ var col: int = idx - text_edit.text.rfind("\n", idx) - 1
+ return Vector2i(col, line)
+
+
+func get_first_non_digit_idx(str: String) -> int:
+ if str.is_empty():
+ return -1
+ if str[0] == "0":
+ return 0 # '0...' is an exception
+ for i in str.length():
+ if !DIGITS.contains(str[i]):
+ return i
+ return -1 # All digits
+
+
+## Repeat the function `f` and accumulate the result. A bit like Array::reduce()
+## f: func(T) -> T where T is the previous output
+func repeat_accum(count: int, inital_value: Variant, f: Callable) -> Variant:
+ var value: Variant = inital_value
+ for _index in count:
+ value = f.call(value)
+ return value
diff --git a/addons/godot_vim/remap.gd b/addons/godot_vim/remap.gd
new file mode 100644
index 0000000..deaab57
--- /dev/null
+++ b/addons/godot_vim/remap.gd
@@ -0,0 +1,178 @@
+class_name KeyRemap extends RefCounted
+
+enum ApplyMode {
+ ## Append this keybind to the end of the list
+ APPEND,
+ ## Insert this keybind at the start of the list
+ PREPEND,
+ ## Insert this keybind at the specified index
+ INSERT,
+ ## Remove the specified keybind
+ REMOVE,
+ ## Replace a keybind with this one
+ REPLACE,
+}
+
+const Constants = preload("res://addons/godot_vim/constants.gd")
+const MODE = Constants.Mode
+
+# Inner cmd
+var inner: Dictionary = {}
+var options: Dictionary = {"apply_mode": ApplyMode.APPEND}
+
+
+func _init(keys: Array[String]):
+ assert(!keys.is_empty(), "cmd_keys cannot be empty")
+ inner = {"keys": keys}
+
+
+## Returns self
+func motion(motion_type: String, args: Dictionary = {}) -> KeyRemap:
+ var m: Dictionary = {"type": motion_type}
+ m.merge(args, true)
+ inner.motion = m
+
+ # Operator + Motion = OperatorMotion
+ if inner.get("type") == KeyMap.Operator:
+ inner.type = KeyMap.OperatorMotion
+ else:
+ inner.type = KeyMap.Motion
+ return self
+
+
+## Returns self
+func operator(operator_type: String, args: Dictionary = {}) -> KeyRemap:
+ var o: Dictionary = {"type": operator_type}
+ o.merge(args, true)
+ inner.operator = o
+
+ # Motion + Operator = OperatorMotion
+ if inner.get("type") == KeyMap.Motion:
+ inner.type = KeyMap.OperatorMotion
+ else:
+ inner.type = KeyMap.Operator
+ return self
+
+
+## Returns self
+func action(action_type: String, args: Dictionary = {}) -> KeyRemap:
+ var a: Dictionary = {"type": action_type}
+ a.merge(args, true)
+ inner.action = a
+ inner.type = KeyMap.Action
+ return self
+
+
+## Returns self
+func with_context(mode: MODE) -> KeyRemap:
+ inner["context"] = mode
+ return self
+
+
+# `key_map` = KeyMap::key_map
+func apply(key_map: Array[Dictionary]):
+ match options.get("apply_mode", ApplyMode.APPEND):
+ ApplyMode.APPEND:
+ key_map.append(inner)
+
+ ApplyMode.PREPEND:
+ var err: int = key_map.insert(0, inner)
+ if err != OK:
+ push_error("[Godot VIM] Failed to prepend keybind: %s" % error_string(err))
+
+ ApplyMode.INSERT:
+ var index: int = options.get("index", 0)
+ var err: int = key_map.insert(index, inner)
+ if err != OK:
+ push_error(
+ (
+ "[Godot VIM] Failed to insert keybind at index %s: %s"
+ % [index, error_string(err)]
+ )
+ )
+
+ ApplyMode.REMOVE:
+ var index: int = _find(key_map, inner)
+ if index == -1:
+ return
+ key_map.remove_at(index)
+
+ ApplyMode.REPLACE:
+ var constraints: Dictionary = {"keys": inner.get("keys", [])}
+ var index: int = _find(key_map, constraints)
+ if index == -1:
+ return
+
+ # print('replacing at index ', index)
+ key_map[index] = inner
+
+
+#region Apply options
+
+
+## Append this keybind to the end of the list
+## Returns self
+func append() -> KeyRemap:
+ options = {"apply_mode": ApplyMode.APPEND}
+ return self
+
+
+## Insert this keybind at the start of the list
+## Returns self
+func prepend() -> KeyRemap:
+ options = {"apply_mode": ApplyMode.PREPEND}
+ return self
+
+
+## Insert this keybind at the specified index
+func insert_at(index: int) -> KeyRemap:
+ options = {
+ "apply_mode": ApplyMode.INSERT,
+ "index": index,
+ }
+ return self
+
+
+## Removes the keybind from the list
+## Returns self
+func remove() -> KeyRemap:
+ options = {"apply_mode": ApplyMode.REMOVE}
+ return self
+
+
+## Replaces the keybind from the list with this new one
+## Returns self
+func replace():
+ options = {"apply_mode": ApplyMode.REPLACE}
+ return self
+
+
+#endregion
+
+
+func _find(key_map: Array[Dictionary], constraints: Dictionary) -> int:
+ var keys: Array[String] = constraints.get("keys")
+ if keys == null:
+ push_error("[Godot VIM::KeyRemap::_find()] Failed to find keybind: keys not specified")
+ return -1
+ if keys.is_empty():
+ push_error("[Godot VIM::KeyRemap::_find()] Failed to find keybind: keys cannot be empty")
+ return -1
+
+ for i in key_map.size():
+ var cmd: Dictionary = key_map[i]
+ # Check keys
+ var m: KeyMap.KeyMatch = KeyMap.match_keys(cmd.keys, keys)
+ if m != KeyMap.KeyMatch.Full:
+ continue
+
+ # If types DON'T match (if specified, ofc), skip
+ if constraints.has("type") and constraints.type != cmd.type:
+ continue
+
+ # If contexts DON'T match (if specified, ofc), skip
+ if constraints.get("context", -1) != cmd.get("context", -1):
+ continue
+
+ return i
+ return -1
diff --git a/addons/godot_vim/status_bar.gd b/addons/godot_vim/status_bar.gd
new file mode 100644
index 0000000..fd52a55
--- /dev/null
+++ b/addons/godot_vim/status_bar.gd
@@ -0,0 +1,88 @@
+extends HBoxContainer
+const ERROR_COLOR: String = "#ff8866"
+const SPECIAL_COLOR: String = "#fcba03"
+
+const Constants = preload("res://addons/godot_vim/constants.gd")
+const MODE = Constants.Mode
+
+var mode_label: Label
+var main_label: RichTextLabel
+var key_label: Label
+
+
+func _ready():
+ var font = load("res://addons/godot_vim/hack_regular.ttf")
+
+ mode_label = Label.new()
+ mode_label.text = ""
+ mode_label.add_theme_color_override(&"font_color", Color.BLACK)
+ var stylebox: StyleBoxFlat = StyleBoxFlat.new()
+ stylebox.bg_color = Color.GOLD
+ stylebox.content_margin_left = 4.0
+ stylebox.content_margin_right = 4.0
+ stylebox.content_margin_top = 2.0
+ stylebox.content_margin_bottom = 2.0
+ mode_label.add_theme_stylebox_override(&"normal", stylebox)
+ mode_label.add_theme_font_override(&"font", font)
+ add_child(mode_label)
+
+ main_label = RichTextLabel.new()
+ main_label.bbcode_enabled = true
+ main_label.text = ""
+ main_label.fit_content = true
+ main_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ main_label.add_theme_font_override(&"normal_font", font)
+ add_child(main_label)
+
+ key_label = Label.new()
+ key_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
+ key_label.text = ""
+ key_label.add_theme_font_override(&"font", font)
+ key_label.custom_minimum_size.x = 120
+ add_child(key_label)
+
+
+func display_text(text: String):
+ main_label.text = text
+
+
+func display_error(text: String):
+ main_label.text = "[color=%s]%s" % [ERROR_COLOR, text]
+
+
+func display_special(text: String):
+ main_label.text = "[color=%s]%s" % [SPECIAL_COLOR, text]
+
+
+func set_mode_text(mode: MODE):
+ var stylebox: StyleBoxFlat = mode_label.get_theme_stylebox(&"normal")
+ match mode:
+ MODE.NORMAL:
+ mode_label.text = "NORMAL"
+ stylebox.bg_color = Color.LIGHT_SALMON
+ MODE.INSERT:
+ mode_label.text = "INSERT"
+ stylebox.bg_color = Color.POWDER_BLUE
+ MODE.VISUAL:
+ mode_label.text = "VISUAL"
+ stylebox.bg_color = Color.PLUM
+ MODE.VISUAL_LINE:
+ mode_label.text = "VISUAL LINE"
+ stylebox.bg_color = Color.PLUM
+ MODE.COMMAND:
+ mode_label.text = "COMMAND"
+ stylebox.bg_color = Color.TOMATO
+ _:
+ pass
+
+
+func set_keys_text(text: String):
+ key_label.text = text
+
+
+func clear():
+ main_label.text = ""
+
+
+func clear_keys():
+ key_label.text = ""
diff --git a/icon.svg b/icon.svg
new file mode 100644
index 0000000..9d8b7fa
--- /dev/null
+++ b/icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/icon.svg.import b/icon.svg.import
new file mode 100644
index 0000000..06fdaad
--- /dev/null
+++ b/icon.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://b10c1776j6j60"
+path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://icon.svg"
+dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/project.godot b/project.godot
new file mode 100644
index 0000000..d95ad36
--- /dev/null
+++ b/project.godot
@@ -0,0 +1,28 @@
+; Engine configuration file.
+; It's best edited using the editor UI and not directly,
+; since the parameters that go here are not all obvious.
+;
+; Format:
+; [section] ; section goes between []
+; param=value ; assign values to parameters
+
+config_version=5
+
+[application]
+
+config/name="Semi-Idle ARPG"
+run/main_scene="res://scenes/testScene.tscn"
+config/features=PackedStringArray("4.3", "Forward Plus")
+config/icon="res://icon.svg"
+
+[display]
+
+window/stretch/scale=3.0
+
+[dotnet]
+
+project/assembly_name="Semi-Idle ARPG"
+
+[editor_plugins]
+
+enabled=PackedStringArray("res://addons/godot-vim/plugin.cfg")
diff --git a/scenes/character.tscn b/scenes/character.tscn
new file mode 100644
index 0000000..58ebc42
--- /dev/null
+++ b/scenes/character.tscn
@@ -0,0 +1,56 @@
+[gd_scene load_steps=2 format=3 uid="uid://ba27ufs8eak0b"]
+
+[ext_resource type="Script" path="res://scripts/character.gd" id="2_bft53"]
+
+[node name="Character" type="Control"]
+layout_mode = 3
+anchors_preset = 0
+script = ExtResource("2_bft53")
+
+[node name="CharacterPanel" type="PanelContainer" parent="."]
+layout_mode = 1
+anchors_preset = 8
+anchor_left = 0.5
+anchor_top = 0.5
+anchor_right = 0.5
+anchor_bottom = 0.5
+offset_left = 567.0
+offset_top = 300.0
+offset_right = 675.0
+offset_bottom = 350.0
+grow_horizontal = 2
+grow_vertical = 2
+size_flags_horizontal = 4
+
+[node name="VBoxContainer" type="VBoxContainer" parent="CharacterPanel"]
+layout_mode = 2
+size_flags_horizontal = 4
+
+[node name="CharacterName" type="Label" parent="CharacterPanel/VBoxContainer"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 4
+text = "Name"
+horizontal_alignment = 1
+
+[node name="HBoxContainer" type="HBoxContainer" parent="CharacterPanel/VBoxContainer"]
+layout_mode = 2
+size_flags_horizontal = 4
+size_flags_vertical = 4
+
+[node name="CharacterCurrHealth" type="Label" parent="CharacterPanel/VBoxContainer/HBoxContainer"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 4
+text = "Current"
+
+[node name="CharacterHealthSep" type="Label" parent="CharacterPanel/VBoxContainer/HBoxContainer"]
+layout_mode = 2
+text = "/"
+
+[node name="CharacterMaxHealth" type="Label" parent="CharacterPanel/VBoxContainer/HBoxContainer"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 4
+text = "Max
+"
diff --git a/scenes/mapTile.tscn b/scenes/mapTile.tscn
new file mode 100644
index 0000000..b2a833d
--- /dev/null
+++ b/scenes/mapTile.tscn
@@ -0,0 +1,6 @@
+[gd_scene load_steps=2 format=3 uid="uid://bavjvxaourccu"]
+
+[ext_resource type="Script" path="res://scripts/map_tile.gd" id="1_jtqny"]
+
+[node name="MapTile" type="Node2D"]
+script = ExtResource("1_jtqny")
diff --git a/scenes/npc.tscn b/scenes/npc.tscn
new file mode 100644
index 0000000..6f5901c
--- /dev/null
+++ b/scenes/npc.tscn
@@ -0,0 +1,12 @@
+[gd_scene load_steps=2 format=3 uid="uid://cdu3sqa0k8dgs"]
+
+[ext_resource type="Script" path="res://scripts/npc.gd" id="2_a7mo0"]
+
+[node name="NPC" type="Control"]
+layout_mode = 3
+anchors_preset = 0
+offset_left = 669.0
+offset_top = 330.0
+offset_right = 669.0
+offset_bottom = 330.0
+script = ExtResource("2_a7mo0")
diff --git a/scenes/testScene.tscn b/scenes/testScene.tscn
new file mode 100644
index 0000000..b455ad4
--- /dev/null
+++ b/scenes/testScene.tscn
@@ -0,0 +1,67 @@
+[gd_scene load_steps=3 format=3 uid="uid://dhvk3terpgsp3"]
+
+[ext_resource type="Script" path="res://scripts/test_scene.gd" id="1_mj8nf"]
+
+[sub_resource type="LabelSettings" id="LabelSettings_ppp5s"]
+
+[node name="TestScene" type="Control"]
+layout_mode = 3
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+script = ExtResource("1_mj8nf")
+
+[node name="MarginContainer" type="MarginContainer" parent="."]
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+
+[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer"]
+layout_mode = 2
+size_flags_vertical = 8
+
+[node name="HBoxContainer" type="HBoxContainer" parent="MarginContainer/VBoxContainer"]
+layout_mode = 2
+
+[node name="OutputLabels" type="VBoxContainer" parent="MarginContainer/VBoxContainer/HBoxContainer"]
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 4
+
+[node name="TestMaxHealth" type="Label" parent="MarginContainer/VBoxContainer/HBoxContainer/OutputLabels"]
+layout_mode = 2
+size_flags_horizontal = 8
+text = "Max Health:"
+horizontal_alignment = 2
+
+[node name="Output" type="VBoxContainer" parent="MarginContainer/VBoxContainer/HBoxContainer"]
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 4
+
+[node name="TestMaxHealthVal" type="Label" parent="MarginContainer/VBoxContainer/HBoxContainer/Output"]
+layout_mode = 2
+size_flags_vertical = 3
+text = "Value"
+label_settings = SubResource("LabelSettings_ppp5s")
+
+[node name="Controls" type="VBoxContainer" parent="MarginContainer/VBoxContainer"]
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 8
+
+[node name="TestButton" type="Button" parent="MarginContainer/VBoxContainer/Controls"]
+layout_mode = 2
+text = "Test"
+
+[node name="ExitButton" type="Button" parent="MarginContainer/VBoxContainer/Controls"]
+layout_mode = 2
+text = "Exit"
+
+[connection signal="pressed" from="MarginContainer/VBoxContainer/Controls/TestButton" to="." method="_on_test_button_pressed"]
+[connection signal="pressed" from="MarginContainer/VBoxContainer/Controls/ExitButton" to="." method="_on_exit_button_pressed"]
diff --git a/scripts/character.gd b/scripts/character.gd
new file mode 100644
index 0000000..7b5572a
--- /dev/null
+++ b/scripts/character.gd
@@ -0,0 +1,12 @@
+class_name Character
+extends Control
+
+
+@export var charName := "Character"
+@export var maxHealth := 10
+@export var dmgTaken := 0
+
+func _init() -> void:
+ %CharacterName.text = self.charName
+ %CharacterMaxHealth.text = str(self.maxHealth)
+ %CharacterCurrHealth.text = str(self.maxHealth - self.dmgTaken)
diff --git a/scripts/equipmentGen.gd b/scripts/equipmentGen.gd
new file mode 100644
index 0000000..0014d44
--- /dev/null
+++ b/scripts/equipmentGen.gd
@@ -0,0 +1,17 @@
+# this script handles everything related to equipment generation
+extends SceneTree
+
+var gearItem = load("gearRand.gd")
+
+
+# func genPiece(tier: int, quality: int):
+# # gernarate a single piece of equipment of tier and quality
+# var gearPiece = gearItem
+#
+# return statValues
+
+
+func _init() -> void:
+ var gearPiece = gearItem.new(tier=2, quality=4)
+ print (gearPiece.statValues)
+ quit(
diff --git a/scripts/gear.gd b/scripts/gear.gd
new file mode 100644
index 0000000..03c2e1e
--- /dev/null
+++ b/scripts/gear.gd
@@ -0,0 +1,22 @@
+class_name Gear
+extends "item.gd"
+
+
+# the basic stats that occur on gear
+const STATLIST = [
+ "str", "dex", "int",
+ "con", "res", "spd",
+ "dot", "crd", "crc"
+ ]
+
+
+var statValues = {}
+var tier: int = 0
+var slot: int = 0
+var skill: int = 0
+
+
+func _init():
+ for stat in STATLIST:
+ statValues[stat] = 0
+
diff --git a/scripts/gearRand.gd b/scripts/gearRand.gd
new file mode 100644
index 0000000..cf0dbe0
--- /dev/null
+++ b/scripts/gearRand.gd
@@ -0,0 +1,39 @@
+class_name GearRandom extends "gear.gd"
+
+
+func _init():
+ # generate random stats piece of gear with tier and quality
+ randomize()
+ var randStatValues = {}
+ # max tier of 10, max stats of 1000
+ var randStatMax = tier*100
+ match quality:
+ 0:
+ # white, common
+ randStatValues["con"] = randi_range(0,tier*100)
+ 1:
+ # green, uncommon
+ var statListQual1 = STATLIST.slice(0, 4)
+ for stat in statListQual1:
+ randStatValues[stat] = randi_range(0, randStatMax)
+ 2:
+ # blue, rare
+ for stat in STATLIST:
+ randStatValues[stat] = randi_range(0, randStatMax)
+ 3:
+ # purple, epic
+ for stat in STATLIST:
+ randStatValues[stat] = randi_range(0.3*randStatMax, randStatMax)
+ 4:
+ # orange?, legendary
+ for stat in STATLIST:
+ randStatValues[stat] = randi_range(0.6*randStatMax, randStatMax)
+ 5:
+ # yellow or red?, artifact
+ for stat in STATLIST:
+ randStatValues[stat] = randi_range(0.9*randStatMax, randStatMax)
+ _:
+ # should never occur, but just in case...
+ print("Unknown Equipment Quality")
+
+ self.statValues = randStatValues
diff --git a/scripts/item.gd b/scripts/item.gd
new file mode 100644
index 0000000..85d02e2
--- /dev/null
+++ b/scripts/item.gd
@@ -0,0 +1,10 @@
+class_name Item
+extends Object
+
+
+var quality: int = 0
+
+func _init() -> void:
+ # simple random integer from 0 to 5 for the different item qualities
+ randomize()
+ self.quality = randi_range(0, 5)
diff --git a/scripts/map_tile.gd b/scripts/map_tile.gd
new file mode 100644
index 0000000..63be33d
--- /dev/null
+++ b/scripts/map_tile.gd
@@ -0,0 +1,12 @@
+extends Node2D
+
+
+# Called when the node enters the scene tree for the first time.
+func _ready() -> void:
+ pass
+
+
+
+# Called every frame. 'delta' is the elapsed time since the previous frame.
+func _process(delta: float) -> void:
+ pass
diff --git a/scripts/npc.gd b/scripts/npc.gd
new file mode 100644
index 0000000..1886b07
--- /dev/null
+++ b/scripts/npc.gd
@@ -0,0 +1,37 @@
+class_name NPC
+extends Character
+
+
+enum npcDifficulties { MINION, NORMAL, MINIBOSS, BOSS, ELITEBOSS, BBEG }
+
+@export var npcDifficulty: npcDifficulties
+@export var tier: int
+
+func _random_mod_health() -> void:
+ randomize()
+ # noise factor
+ var noise_factor = randf_range(7, 10)
+ self.maxHealth *= noise_factor
+ # difficulty factor
+ match self.npcDifficulty:
+ npcDifficulties.MINION:
+ self.maxHealth /= 2
+ npcDifficulties.MINIBOSS:
+ self.maxHealth *= 2
+ npcDifficulties.BOSS:
+ self.maxHealth *= 4
+ npcDifficulties.ELITEBOSS:
+ self.maxHealth *= 8
+ npcDifficulties.BBEG:
+ self.maxHealth *= 16
+ # tier factor
+ self.maxHealth *= exp(self.tier)
+
+ # fun factor (just additional factor to tweak)
+ self.maxHealth *= 10
+
+
+func _init(npcDifficulty: npcDifficulties = npcDifficulties.NORMAL, tier: int = 0) -> void:
+ self.npcDifficulty = npcDifficulty
+ self.tier = tier
+ _random_mod_health()
diff --git a/scripts/test_scene.gd b/scripts/test_scene.gd
new file mode 100644
index 0000000..178e5a5
--- /dev/null
+++ b/scripts/test_scene.gd
@@ -0,0 +1,33 @@
+extends Control
+
+
+func _on_exit_button_pressed() -> void:
+ get_tree().quit()
+
+
+func _on_test_button_pressed() -> void:
+ var rand_difficulty = NPC.npcDifficulties.values().pick_random()
+ var rand_tier = randi_range(0, 10)
+ var anNPC = NPC.new(rand_difficulty, rand_tier)
+ var TestMaxHealthVal = $MarginContainer/HBoxContainer/Output/TestMaxHealthVal
+ TestMaxHealthVal.text = str(anNPC.maxHealth)
+ #TestMaxHealthVal.label_settings = LabelSettings.new()
+ TestMaxHealthVal.label_settings.outline_size = 4
+ TestMaxHealthVal.label_settings.outline_color = Color("#1D2021")
+ match anNPC.npcDifficulty:
+ NPC.npcDifficulties.MINION:
+ TestMaxHealthVal.label_settings.font_color = Color.LIGHT_GRAY
+ NPC.npcDifficulties.NORMAL:
+ TestMaxHealthVal.label_settings.font_color = Color.SEA_GREEN
+ NPC.npcDifficulties.MINIBOSS:
+ TestMaxHealthVal.label_settings.font_color = Color.ROYAL_BLUE
+ NPC.npcDifficulties.BOSS:
+ TestMaxHealthVal.label_settings.font_color = Color.PURPLE
+ NPC.npcDifficulties.ELITEBOSS:
+ TestMaxHealthVal.label_settings.font_color = Color.ORANGE
+ NPC.npcDifficulties.BBEG:
+ TestMaxHealthVal.label_settings.font_color = Color.GOLD
+ TestMaxHealthVal.label_settings.outline_size = 8
+ TestMaxHealthVal.label_settings.outline_color = Color.DARK_RED
+ #anNPC.position = Vector2(64, 64)
+ #add_child(anNPC)