semi-idle-arpg/addons/godot-vim/godot-vim.gd

1703 lines
74 KiB
GDScript3
Raw Normal View History

2024-09-19 19:03:06 -06:00
@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