mui 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +163 -0
  3. data/CHANGELOG.md +448 -0
  4. data/README.md +309 -6
  5. data/docs/_config.yml +56 -0
  6. data/docs/configuration.md +301 -0
  7. data/docs/getting-started.md +140 -0
  8. data/docs/index.md +55 -0
  9. data/docs/jobs.md +297 -0
  10. data/docs/keybindings.md +229 -0
  11. data/docs/plugins.md +285 -0
  12. data/docs/syntax-highlighting.md +149 -0
  13. data/exe/mui +1 -2
  14. data/lib/mui/autocmd.rb +66 -0
  15. data/lib/mui/buffer.rb +275 -0
  16. data/lib/mui/buffer_word_cache.rb +131 -0
  17. data/lib/mui/buffer_word_completer.rb +77 -0
  18. data/lib/mui/color_manager.rb +136 -0
  19. data/lib/mui/color_scheme.rb +63 -0
  20. data/lib/mui/command_completer.rb +30 -0
  21. data/lib/mui/command_context.rb +90 -0
  22. data/lib/mui/command_history.rb +89 -0
  23. data/lib/mui/command_line.rb +167 -0
  24. data/lib/mui/command_registry.rb +44 -0
  25. data/lib/mui/completion_renderer.rb +84 -0
  26. data/lib/mui/completion_state.rb +58 -0
  27. data/lib/mui/config.rb +58 -0
  28. data/lib/mui/editor.rb +395 -0
  29. data/lib/mui/error.rb +29 -0
  30. data/lib/mui/file_completer.rb +51 -0
  31. data/lib/mui/floating_window.rb +161 -0
  32. data/lib/mui/handler_result.rb +107 -0
  33. data/lib/mui/highlight.rb +22 -0
  34. data/lib/mui/highlighters/base.rb +23 -0
  35. data/lib/mui/highlighters/search_highlighter.rb +27 -0
  36. data/lib/mui/highlighters/selection_highlighter.rb +48 -0
  37. data/lib/mui/highlighters/syntax_highlighter.rb +107 -0
  38. data/lib/mui/input.rb +17 -0
  39. data/lib/mui/insert_completion_renderer.rb +92 -0
  40. data/lib/mui/insert_completion_state.rb +77 -0
  41. data/lib/mui/job.rb +81 -0
  42. data/lib/mui/job_manager.rb +113 -0
  43. data/lib/mui/key_code.rb +30 -0
  44. data/lib/mui/key_handler/base.rb +187 -0
  45. data/lib/mui/key_handler/command_mode.rb +511 -0
  46. data/lib/mui/key_handler/insert_mode.rb +323 -0
  47. data/lib/mui/key_handler/motions/motion_handler.rb +56 -0
  48. data/lib/mui/key_handler/normal_mode.rb +552 -0
  49. data/lib/mui/key_handler/operators/base_operator.rb +134 -0
  50. data/lib/mui/key_handler/operators/change_operator.rb +179 -0
  51. data/lib/mui/key_handler/operators/delete_operator.rb +176 -0
  52. data/lib/mui/key_handler/operators/paste_operator.rb +119 -0
  53. data/lib/mui/key_handler/operators/yank_operator.rb +127 -0
  54. data/lib/mui/key_handler/search_mode.rb +191 -0
  55. data/lib/mui/key_handler/visual_line_mode.rb +20 -0
  56. data/lib/mui/key_handler/visual_mode.rb +402 -0
  57. data/lib/mui/key_handler/window_command.rb +112 -0
  58. data/lib/mui/key_handler.rb +16 -0
  59. data/lib/mui/key_notation_parser.rb +152 -0
  60. data/lib/mui/key_sequence.rb +67 -0
  61. data/lib/mui/key_sequence_buffer.rb +85 -0
  62. data/lib/mui/key_sequence_handler.rb +163 -0
  63. data/lib/mui/key_sequence_matcher.rb +79 -0
  64. data/lib/mui/layout/calculator.rb +15 -0
  65. data/lib/mui/layout/leaf_node.rb +33 -0
  66. data/lib/mui/layout/node.rb +29 -0
  67. data/lib/mui/layout/split_node.rb +132 -0
  68. data/lib/mui/line_renderer.rb +173 -0
  69. data/lib/mui/mode.rb +13 -0
  70. data/lib/mui/mode_manager.rb +186 -0
  71. data/lib/mui/motion.rb +139 -0
  72. data/lib/mui/plugin.rb +35 -0
  73. data/lib/mui/plugin_manager.rb +106 -0
  74. data/lib/mui/register.rb +110 -0
  75. data/lib/mui/screen.rb +103 -0
  76. data/lib/mui/search_completer.rb +50 -0
  77. data/lib/mui/search_input.rb +40 -0
  78. data/lib/mui/search_state.rb +121 -0
  79. data/lib/mui/selection.rb +55 -0
  80. data/lib/mui/status_line_renderer.rb +40 -0
  81. data/lib/mui/syntax/language_detector.rb +106 -0
  82. data/lib/mui/syntax/lexer_base.rb +106 -0
  83. data/lib/mui/syntax/lexers/c_lexer.rb +127 -0
  84. data/lib/mui/syntax/lexers/css_lexer.rb +121 -0
  85. data/lib/mui/syntax/lexers/go_lexer.rb +205 -0
  86. data/lib/mui/syntax/lexers/html_lexer.rb +118 -0
  87. data/lib/mui/syntax/lexers/javascript_lexer.rb +197 -0
  88. data/lib/mui/syntax/lexers/markdown_lexer.rb +210 -0
  89. data/lib/mui/syntax/lexers/ruby_lexer.rb +114 -0
  90. data/lib/mui/syntax/lexers/rust_lexer.rb +148 -0
  91. data/lib/mui/syntax/lexers/typescript_lexer.rb +203 -0
  92. data/lib/mui/syntax/token.rb +42 -0
  93. data/lib/mui/syntax/token_cache.rb +91 -0
  94. data/lib/mui/tab_bar_renderer.rb +87 -0
  95. data/lib/mui/tab_manager.rb +96 -0
  96. data/lib/mui/tab_page.rb +35 -0
  97. data/lib/mui/terminal_adapter/base.rb +92 -0
  98. data/lib/mui/terminal_adapter/curses.rb +164 -0
  99. data/lib/mui/terminal_adapter.rb +4 -0
  100. data/lib/mui/themes/default.rb +315 -0
  101. data/lib/mui/undo_manager.rb +83 -0
  102. data/lib/mui/undoable_action.rb +175 -0
  103. data/lib/mui/unicode_width.rb +100 -0
  104. data/lib/mui/version.rb +1 -1
  105. data/lib/mui/window.rb +201 -0
  106. data/lib/mui/window_manager.rb +256 -0
  107. data/lib/mui/wrap_cache.rb +40 -0
  108. data/lib/mui/wrap_helper.rb +84 -0
  109. data/lib/mui.rb +171 -2
  110. metadata +123 -5
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ # Provides plugin access to editor internals
5
+ class CommandContext
6
+ attr_reader :buffer, :window, :editor
7
+
8
+ def initialize(editor:, buffer:, window:)
9
+ @editor = editor
10
+ @buffer = buffer
11
+ @window = window
12
+ end
13
+
14
+ def cursor
15
+ { line: @buffer.cursor_y, col: @buffer.cursor_x }
16
+ end
17
+
18
+ def current_line
19
+ @buffer.current_line
20
+ end
21
+
22
+ def insert(text)
23
+ @buffer.insert_text(text)
24
+ end
25
+
26
+ def set_message(msg)
27
+ @editor.message = msg
28
+ end
29
+
30
+ def quit
31
+ @editor.running = false
32
+ end
33
+
34
+ def run_command(name, *)
35
+ @editor.command_registry.execute(name, self, *)
36
+ end
37
+
38
+ def run_async(on_complete: nil, &)
39
+ @editor.job_manager.run_async(on_complete:, &)
40
+ end
41
+
42
+ def run_shell_command(cmd, on_complete: nil)
43
+ @editor.job_manager.run_command(cmd, on_complete:)
44
+ end
45
+
46
+ def jobs_running?
47
+ @editor.job_manager.busy?
48
+ end
49
+
50
+ def cancel_job(id)
51
+ @editor.job_manager.cancel(id)
52
+ end
53
+
54
+ def open_scratch_buffer(name, content)
55
+ @editor.open_scratch_buffer(name, content)
56
+ end
57
+
58
+ # Run an interactive command that needs terminal access (e.g., fzf)
59
+ # Suspends Curses UI, runs command, resumes UI
60
+ def run_interactive_command(cmd)
61
+ require "tempfile"
62
+
63
+ @editor.suspend_ui do
64
+ output_file = Tempfile.new("mui_interactive")
65
+ begin
66
+ # Use shell redirection to capture output while keeping stdin/stderr connected to terminal
67
+ # rubocop:disable Style/SpecialGlobalVars
68
+ success = system("#{cmd} > #{output_file.path}")
69
+ status = $?
70
+ # rubocop:enable Style/SpecialGlobalVars
71
+ exit_status = status&.exitstatus || 1
72
+ {
73
+ stdout: File.read(output_file.path),
74
+ stderr: "",
75
+ exit_status:,
76
+ success: success == true && exit_status.zero?
77
+ }
78
+ ensure
79
+ output_file.close
80
+ output_file.unlink
81
+ end
82
+ end
83
+ end
84
+
85
+ # Check if a command exists in PATH
86
+ def command_exists?(cmd)
87
+ system("which #{cmd} > /dev/null 2>&1")
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ # Manages command history with file persistence
5
+ class CommandHistory
6
+ MAX_HISTORY = 100
7
+ HISTORY_FILE = File.expand_path("~/.mui_history")
8
+
9
+ attr_reader :history
10
+
11
+ def initialize(history_file: HISTORY_FILE)
12
+ @history_file = history_file
13
+ @history = []
14
+ @index = nil
15
+ @saved_input = nil
16
+ load_from_file
17
+ end
18
+
19
+ def add(command)
20
+ return if command.strip.empty?
21
+
22
+ @history.delete(command)
23
+ @history.push(command)
24
+ @history.shift if @history.size > MAX_HISTORY
25
+ save_to_file
26
+ end
27
+
28
+ def previous(current_input)
29
+ return nil if @history.empty?
30
+
31
+ if @index.nil?
32
+ @saved_input = current_input
33
+ @index = @history.size - 1
34
+ elsif @index.positive?
35
+ @index -= 1
36
+ else
37
+ return nil
38
+ end
39
+
40
+ @history[@index]
41
+ end
42
+
43
+ def next_entry
44
+ return nil if @index.nil?
45
+
46
+ if @index < @history.size - 1
47
+ @index += 1
48
+ @history[@index]
49
+ else
50
+ result = @saved_input
51
+ reset
52
+ result
53
+ end
54
+ end
55
+
56
+ def reset
57
+ @index = nil
58
+ @saved_input = nil
59
+ end
60
+
61
+ def browsing?
62
+ !@index.nil?
63
+ end
64
+
65
+ def size
66
+ @history.size
67
+ end
68
+
69
+ def empty?
70
+ @history.empty?
71
+ end
72
+
73
+ private
74
+
75
+ def load_from_file
76
+ return unless File.exist?(@history_file)
77
+
78
+ @history = File.readlines(@history_file, chomp: true).last(MAX_HISTORY)
79
+ rescue StandardError
80
+ @history = []
81
+ end
82
+
83
+ def save_to_file
84
+ File.write(@history_file, "#{@history.join("\n")}\n")
85
+ rescue StandardError
86
+ # Ignore write failures
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ class CommandLine
5
+ # Commands that accept file path arguments
6
+ FILE_COMMANDS = %w[e w sp split vs vsplit tabnew tabe tabedit].freeze
7
+
8
+ attr_reader :buffer, :cursor_pos, :history
9
+
10
+ def initialize(history: CommandHistory.new)
11
+ @buffer = ""
12
+ @cursor_pos = 0
13
+ @history = history
14
+ end
15
+
16
+ def input(char)
17
+ @buffer = @buffer[0...@cursor_pos].to_s + char + @buffer[@cursor_pos..].to_s
18
+ @cursor_pos += char.length
19
+ end
20
+
21
+ def backspace
22
+ return if @cursor_pos.zero?
23
+
24
+ @buffer = @buffer[0...(@cursor_pos - 1)].to_s + @buffer[@cursor_pos..].to_s
25
+ @cursor_pos -= 1
26
+ end
27
+
28
+ def clear
29
+ @buffer = ""
30
+ @cursor_pos = 0
31
+ @history.reset
32
+ end
33
+
34
+ def history_previous
35
+ result = @history.previous(@buffer)
36
+ return false unless result
37
+
38
+ @buffer = result.dup
39
+ @cursor_pos = @buffer.length
40
+ true
41
+ end
42
+
43
+ def history_next
44
+ result = @history.next_entry
45
+ return false unless result
46
+
47
+ @buffer = result.dup
48
+ @cursor_pos = @buffer.length
49
+ true
50
+ end
51
+
52
+ def move_cursor_left
53
+ @cursor_pos -= 1 if @cursor_pos.positive?
54
+ end
55
+
56
+ def move_cursor_right
57
+ @cursor_pos += 1 if @cursor_pos < @buffer.length
58
+ end
59
+
60
+ def execute
61
+ command = @buffer.strip
62
+ @history.add(command) unless command.empty?
63
+ @history.reset
64
+ result = parse(@buffer)
65
+ @buffer = ""
66
+ @cursor_pos = 0
67
+ result
68
+ end
69
+
70
+ def to_s
71
+ ":#{@buffer}"
72
+ end
73
+
74
+ # Determine completion context based on current buffer
75
+ def completion_context
76
+ # Check if buffer contains a space (command + argument)
77
+ if @buffer.include?(" ")
78
+ # Command with argument
79
+ parts = @buffer.split(/\s+/, 2)
80
+ command = parts[0]
81
+ arg = parts[1] || ""
82
+
83
+ return { type: :file, command:, prefix: arg } if FILE_COMMANDS.include?(command)
84
+
85
+ # Return nil for commands that don't support file completion
86
+ return nil
87
+ end
88
+
89
+ # No space -> command completion
90
+ { type: :command, prefix: @buffer.strip }
91
+ end
92
+
93
+ # Apply completion result to buffer
94
+ def apply_completion(text, context)
95
+ @buffer = if context[:type] == :command
96
+ text
97
+ else
98
+ "#{context[:command]} #{text}"
99
+ end
100
+ @cursor_pos = @buffer.length
101
+ end
102
+
103
+ private
104
+
105
+ def parse(cmd)
106
+ case cmd.strip
107
+ when ""
108
+ { action: :no_op }
109
+ when "e"
110
+ { action: :open }
111
+ when /^e\s+(.+)/
112
+ { action: :open_as, path: ::Regexp.last_match(1) }
113
+ when "w"
114
+ { action: :write }
115
+ when "q"
116
+ { action: :quit }
117
+ when "wq"
118
+ { action: :write_quit }
119
+ when "q!"
120
+ { action: :force_quit }
121
+ when /^w\s+(.+)/
122
+ { action: :write_as, path: ::Regexp.last_match(1) }
123
+ when "sp", "split"
124
+ { action: :split_horizontal }
125
+ when /^sp\s+(.+)/, /^split\s+(.+)/
126
+ { action: :split_horizontal, path: ::Regexp.last_match(1) }
127
+ when "vs", "vsplit"
128
+ { action: :split_vertical }
129
+ when /^vs\s+(.+)/, /^vsplit\s+(.+)/
130
+ { action: :split_vertical, path: ::Regexp.last_match(1) }
131
+ when "close"
132
+ { action: :close_window }
133
+ when "only"
134
+ { action: :only_window }
135
+ when "tabnew", "tabe", "tabedit"
136
+ { action: :tab_new }
137
+ when /^tabnew\s+(.+)/, /^tabe\s+(.+)/, /^tabedit\s+(.+)/
138
+ { action: :tab_new, path: ::Regexp.last_match(1) }
139
+ when "tabclose", "tabc"
140
+ { action: :tab_close }
141
+ when "tabnext", "tabn"
142
+ { action: :tab_next }
143
+ when "tabprev", "tabp", "tabprevious"
144
+ { action: :tab_prev }
145
+ when "tabfirst", "tabf", "tabrewind", "tabr"
146
+ { action: :tab_first }
147
+ when "tablast", "tabl"
148
+ { action: :tab_last }
149
+ when /^tabmove\s+(\d+)/, /^tabm\s+(\d+)/
150
+ { action: :tab_move, position: ::Regexp.last_match(1).to_i }
151
+ when /^(\d+)tabn(?:ext)?/, /^tabn(?:ext)?\s+(\d+)/
152
+ { action: :tab_go, index: ::Regexp.last_match(1).to_i - 1 }
153
+ when /^!(.*)$/
154
+ shell_cmd = ::Regexp.last_match(1).strip
155
+ if shell_cmd.empty?
156
+ { action: :shell_command_error, message: "E471: Argument required" }
157
+ else
158
+ { action: :shell_command, command: shell_cmd }
159
+ end
160
+ when /^(\d+)$/
161
+ { action: :goto_line, line_number: ::Regexp.last_match(1).to_i }
162
+ else
163
+ { action: :unknown, command: cmd }
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ # Registry for Ex commands
5
+ class CommandRegistry
6
+ def initialize
7
+ @commands = {}
8
+ end
9
+
10
+ def register(name, &block)
11
+ @commands[name.to_sym] = block
12
+ end
13
+
14
+ def execute(name, context, *)
15
+ command = find(name)
16
+ raise UnknownCommandError, name unless command
17
+
18
+ command.call(context, *)
19
+ end
20
+
21
+ def exists?(name)
22
+ @commands.key?(name.to_sym) || plugin_command_exists?(name)
23
+ end
24
+
25
+ def find(name)
26
+ # Built-in commands take precedence
27
+ command = @commands[name.to_sym]
28
+ return command if command
29
+
30
+ # Fall back to plugin commands
31
+ plugin_commands[name.to_sym]
32
+ end
33
+
34
+ private
35
+
36
+ def plugin_command_exists?(name)
37
+ plugin_commands.key?(name.to_sym)
38
+ end
39
+
40
+ def plugin_commands
41
+ Mui.config.commands
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ # Renders completion popup menu
5
+ class CompletionRenderer
6
+ MAX_VISIBLE_ITEMS = 10
7
+
8
+ def initialize(screen, color_scheme)
9
+ @screen = screen
10
+ @color_scheme = color_scheme
11
+ end
12
+
13
+ def render(completion_state, base_row, base_col)
14
+ return unless completion_state.active?
15
+
16
+ candidates = completion_state.candidates
17
+ selected_index = completion_state.selected_index
18
+
19
+ # Calculate visible window
20
+ visible_start, visible_end = calculate_visible_range(candidates.length, selected_index)
21
+ visible_candidates = candidates[visible_start...visible_end]
22
+
23
+ # Calculate popup dimensions
24
+ max_width = calculate_max_width(visible_candidates)
25
+ popup_height = visible_candidates.length
26
+
27
+ # Calculate position (popup appears above the command line)
28
+ popup_row = base_row - popup_height
29
+ popup_col = base_col
30
+
31
+ # Ensure popup stays within screen bounds
32
+ popup_col = [@screen.width - max_width - 1, popup_col].min
33
+ popup_col = [0, popup_col].max
34
+ popup_row = [0, popup_row].max
35
+
36
+ # Render each visible candidate
37
+ visible_candidates.each_with_index do |candidate, i|
38
+ actual_index = visible_start + i
39
+ is_selected = actual_index == selected_index
40
+
41
+ render_item(
42
+ candidate,
43
+ popup_row + i,
44
+ popup_col,
45
+ max_width,
46
+ is_selected
47
+ )
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def calculate_visible_range(total_count, selected_index)
54
+ return [0, total_count] if total_count <= MAX_VISIBLE_ITEMS
55
+
56
+ # Try to center the selected item
57
+ half = MAX_VISIBLE_ITEMS / 2
58
+ start_index = selected_index - half
59
+ start_index = [0, start_index].max
60
+ end_index = start_index + MAX_VISIBLE_ITEMS
61
+ end_index = [total_count, end_index].min
62
+ start_index = end_index - MAX_VISIBLE_ITEMS
63
+
64
+ [start_index, end_index]
65
+ end
66
+
67
+ def calculate_max_width(candidates)
68
+ return 0 if candidates.empty?
69
+
70
+ candidates.map { |c| display_width(c) }.max + 2 # +2 for padding
71
+ end
72
+
73
+ def display_width(text)
74
+ UnicodeWidth.string_width(text)
75
+ end
76
+
77
+ def render_item(text, row, col, width, selected)
78
+ style_key = selected ? :completion_popup_selected : :completion_popup
79
+ style = @color_scheme[style_key]
80
+ padded_text = " #{text}".ljust(width)
81
+ @screen.put_with_style(row, col, padded_text, style)
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ # Manages completion popup state
5
+ class CompletionState
6
+ attr_reader :candidates, :selected_index, :original_input, :completion_type
7
+
8
+ def initialize
9
+ reset
10
+ end
11
+
12
+ def reset
13
+ @candidates = []
14
+ @selected_index = 0
15
+ @original_input = nil
16
+ @completion_type = nil # :command or :file
17
+ @confirmed = false
18
+ end
19
+
20
+ def active?
21
+ !@candidates.empty?
22
+ end
23
+
24
+ def start(candidates, original_input, type)
25
+ @candidates = candidates
26
+ @selected_index = 0
27
+ @original_input = original_input
28
+ @completion_type = type
29
+ @confirmed = false
30
+ end
31
+
32
+ def confirm
33
+ @confirmed = true
34
+ end
35
+
36
+ def confirmed?
37
+ @confirmed
38
+ end
39
+
40
+ def select_next
41
+ return unless active?
42
+
43
+ @selected_index = (@selected_index + 1) % @candidates.length
44
+ end
45
+
46
+ def select_previous
47
+ return unless active?
48
+
49
+ @selected_index = (@selected_index - 1) % @candidates.length
50
+ end
51
+
52
+ def current_candidate
53
+ return nil unless active?
54
+
55
+ @candidates[@selected_index]
56
+ end
57
+ end
58
+ end
data/lib/mui/config.rb ADDED
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ class Config
5
+ attr_reader :options, :plugins, :keymaps, :commands, :autocmds
6
+
7
+ def initialize
8
+ @options = {
9
+ colorscheme: "mui",
10
+ syntax: true, # Enable/disable syntax highlighting
11
+ shiftwidth: 2, # Indent width for > and < commands
12
+ expandtab: true, # Use spaces instead of tabs
13
+ tabstop: 8, # Tab display width
14
+ reselect_after_indent: false, # Keep selection after > / < in visual mode
15
+ leader: "\\", # Leader key for key mappings (default: backslash)
16
+ timeoutlen: 1000 # Timeout for multi-key sequences in milliseconds
17
+ }
18
+ @plugins = []
19
+ @keymaps = {}
20
+ @commands = {}
21
+ @autocmds = {}
22
+ end
23
+
24
+ def set(key, value)
25
+ @options[key.to_sym] = value
26
+ end
27
+
28
+ def get(key)
29
+ @options[key.to_sym]
30
+ end
31
+
32
+ def load_file(path)
33
+ return unless File.exist?(path)
34
+
35
+ instance_eval(File.read(path), path)
36
+ end
37
+
38
+ # Stub for future plugin support
39
+ def use_plugin(gem_name, version = nil)
40
+ @plugins << { gem: gem_name, version: }
41
+ end
42
+
43
+ # Stub for future keymap support
44
+ def add_keymap(mode, key, block)
45
+ @keymaps[mode] ||= {}
46
+ @keymaps[mode][key] = block
47
+ end
48
+
49
+ def add_command(name, block)
50
+ @commands[name.to_sym] = block
51
+ end
52
+
53
+ def add_autocmd(event, pattern, block)
54
+ @autocmds[event.to_sym] ||= []
55
+ @autocmds[event.to_sym] << { pattern:, handler: block }
56
+ end
57
+ end
58
+ end