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)