semi-idle-arpg/addons/godot_vim/key_map.gd
2024-09-19 19:03:06 -06:00

742 lines
22 KiB
GDScript

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([ "<C-[>" ])
# .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": ["<C-u>"],
"type": Motion,
"motion": {"type": "move_by_screen", "percentage": -0.5, "line_wise": true}
},
{
"keys": ["<C-d>"],
"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": ["<C-r>"], "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] = [
"<C-s>", # Save
"<C-b>", # 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 "<CR>"
if event.keycode == KEY_TAB:
return "<TAB>"
if event.keycode == KEY_ESCAPE:
return "<ESC>"
# 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-%s>" % [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)