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 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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)