mui 0.2.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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +13 -8
- data/CHANGELOG.md +99 -0
- data/README.md +309 -6
- data/docs/_config.yml +56 -0
- data/docs/configuration.md +301 -0
- data/docs/getting-started.md +140 -0
- data/docs/index.md +55 -0
- data/docs/jobs.md +297 -0
- data/docs/keybindings.md +229 -0
- data/docs/plugins.md +285 -0
- data/docs/syntax-highlighting.md +149 -0
- data/lib/mui/command_completer.rb +11 -2
- data/lib/mui/command_history.rb +89 -0
- data/lib/mui/command_line.rb +32 -2
- data/lib/mui/command_registry.rb +21 -2
- data/lib/mui/config.rb +3 -1
- data/lib/mui/editor.rb +78 -2
- data/lib/mui/handler_result.rb +13 -7
- data/lib/mui/highlighters/search_highlighter.rb +2 -1
- data/lib/mui/highlighters/syntax_highlighter.rb +3 -1
- data/lib/mui/key_handler/base.rb +87 -0
- data/lib/mui/key_handler/command_mode.rb +68 -0
- data/lib/mui/key_handler/insert_mode.rb +10 -41
- data/lib/mui/key_handler/normal_mode.rb +24 -51
- data/lib/mui/key_handler/operators/paste_operator.rb +9 -3
- data/lib/mui/key_handler/search_mode.rb +10 -7
- data/lib/mui/key_handler/visual_mode.rb +15 -10
- data/lib/mui/key_notation_parser.rb +152 -0
- data/lib/mui/key_sequence.rb +67 -0
- data/lib/mui/key_sequence_buffer.rb +85 -0
- data/lib/mui/key_sequence_handler.rb +163 -0
- data/lib/mui/key_sequence_matcher.rb +79 -0
- data/lib/mui/line_renderer.rb +52 -1
- data/lib/mui/mode_manager.rb +3 -2
- data/lib/mui/screen.rb +24 -6
- data/lib/mui/search_state.rb +61 -28
- data/lib/mui/syntax/language_detector.rb +33 -1
- data/lib/mui/syntax/lexers/css_lexer.rb +121 -0
- data/lib/mui/syntax/lexers/go_lexer.rb +205 -0
- data/lib/mui/syntax/lexers/html_lexer.rb +118 -0
- data/lib/mui/syntax/lexers/javascript_lexer.rb +197 -0
- data/lib/mui/syntax/lexers/markdown_lexer.rb +210 -0
- data/lib/mui/syntax/lexers/rust_lexer.rb +148 -0
- data/lib/mui/syntax/lexers/typescript_lexer.rb +203 -0
- data/lib/mui/terminal_adapter/curses.rb +13 -11
- data/lib/mui/version.rb +1 -1
- data/lib/mui/window.rb +83 -40
- data/lib/mui/window_manager.rb +7 -0
- data/lib/mui/wrap_cache.rb +40 -0
- data/lib/mui/wrap_helper.rb +84 -0
- data/lib/mui.rb +15 -0
- metadata +26 -3
|
@@ -37,6 +37,7 @@ module Mui
|
|
|
37
37
|
private
|
|
38
38
|
|
|
39
39
|
def paste_line_after(name: nil)
|
|
40
|
+
undo_manager&.begin_group
|
|
40
41
|
text = @register.get(name:)
|
|
41
42
|
lines = text.split("\n", -1)
|
|
42
43
|
lines.reverse_each do |line|
|
|
@@ -44,15 +45,18 @@ module Mui
|
|
|
44
45
|
end
|
|
45
46
|
self.cursor_row = cursor_row + 1
|
|
46
47
|
self.cursor_col = 0
|
|
48
|
+
undo_manager&.end_group
|
|
47
49
|
end
|
|
48
50
|
|
|
49
51
|
def paste_line_before(name: nil)
|
|
52
|
+
undo_manager&.begin_group
|
|
50
53
|
text = @register.get(name:)
|
|
51
54
|
lines = text.split("\n", -1)
|
|
52
55
|
lines.reverse_each do |line|
|
|
53
56
|
@buffer.insert_line(cursor_row, line)
|
|
54
57
|
end
|
|
55
58
|
self.cursor_col = 0
|
|
59
|
+
undo_manager&.end_group
|
|
56
60
|
end
|
|
57
61
|
|
|
58
62
|
def paste_char_after(name: nil)
|
|
@@ -63,7 +67,7 @@ module Mui
|
|
|
63
67
|
if text.include?("\n")
|
|
64
68
|
paste_multiline_char(text, line, insert_col)
|
|
65
69
|
else
|
|
66
|
-
@buffer.
|
|
70
|
+
@buffer.replace_line(cursor_row, line[0...insert_col].to_s + text + line[insert_col..].to_s)
|
|
67
71
|
self.cursor_col = insert_col + text.length - 1
|
|
68
72
|
@window.clamp_cursor_to_line(@buffer)
|
|
69
73
|
end
|
|
@@ -76,19 +80,20 @@ module Mui
|
|
|
76
80
|
if text.include?("\n")
|
|
77
81
|
paste_multiline_char(text, line, cursor_col)
|
|
78
82
|
else
|
|
79
|
-
@buffer.
|
|
83
|
+
@buffer.replace_line(cursor_row, line[0...cursor_col].to_s + text + line[cursor_col..].to_s)
|
|
80
84
|
self.cursor_col = cursor_col + text.length - 1
|
|
81
85
|
@window.clamp_cursor_to_line(@buffer)
|
|
82
86
|
end
|
|
83
87
|
end
|
|
84
88
|
|
|
85
89
|
def paste_multiline_char(text, line, insert_col)
|
|
90
|
+
undo_manager&.begin_group
|
|
86
91
|
lines = text.split("\n", -1)
|
|
87
92
|
before = line[0...insert_col].to_s
|
|
88
93
|
after = line[insert_col..].to_s
|
|
89
94
|
|
|
90
95
|
# First line: before + first part of pasted text
|
|
91
|
-
@buffer.
|
|
96
|
+
@buffer.replace_line(cursor_row, before + lines.first)
|
|
92
97
|
|
|
93
98
|
# Middle lines: insert as new lines
|
|
94
99
|
lines[1...-1].each_with_index do |pasted_line, idx|
|
|
@@ -106,6 +111,7 @@ module Mui
|
|
|
106
111
|
self.cursor_col = lines.last.length - 1
|
|
107
112
|
self.cursor_col = 0 if cursor_col.negative?
|
|
108
113
|
@window.clamp_cursor_to_line(@buffer)
|
|
114
|
+
undo_manager&.end_group
|
|
109
115
|
end
|
|
110
116
|
end
|
|
111
117
|
end
|
|
@@ -23,6 +23,10 @@ module Mui
|
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
def handle(key)
|
|
26
|
+
# Check plugin keymaps first
|
|
27
|
+
plugin_result = check_plugin_keymap(key, :search)
|
|
28
|
+
return plugin_result if plugin_result
|
|
29
|
+
|
|
26
30
|
case key
|
|
27
31
|
when KeyCode::ESCAPE
|
|
28
32
|
handle_escape
|
|
@@ -136,19 +140,19 @@ module Mui
|
|
|
136
140
|
|
|
137
141
|
direction = @search_input.prompt == "/" ? :forward : :backward
|
|
138
142
|
@search_state.set_pattern(pattern, direction)
|
|
139
|
-
@search_state.find_all_matches(buffer)
|
|
140
143
|
|
|
141
144
|
# Move cursor to first match from original position
|
|
142
|
-
|
|
145
|
+
matches = @search_state.find_all_matches(buffer)
|
|
146
|
+
return if matches.empty?
|
|
143
147
|
|
|
144
148
|
# Use original position if set, otherwise use current cursor position
|
|
145
149
|
search_row = @original_cursor_row || cursor_row
|
|
146
150
|
search_col = @original_cursor_col || cursor_col
|
|
147
151
|
|
|
148
152
|
match = if direction == :forward
|
|
149
|
-
@search_state.find_next(search_row, search_col)
|
|
153
|
+
@search_state.find_next(search_row, search_col, buffer:)
|
|
150
154
|
else
|
|
151
|
-
@search_state.find_previous(search_row, search_col)
|
|
155
|
+
@search_state.find_previous(search_row, search_col, buffer:)
|
|
152
156
|
end
|
|
153
157
|
|
|
154
158
|
return unless match
|
|
@@ -163,12 +167,11 @@ module Mui
|
|
|
163
167
|
|
|
164
168
|
direction = @search_input.prompt == "/" ? :forward : :backward
|
|
165
169
|
@search_state.set_pattern(pattern, direction)
|
|
166
|
-
@search_state.find_all_matches(@buffer)
|
|
167
170
|
|
|
168
171
|
match = if direction == :forward
|
|
169
|
-
@search_state.find_next(cursor_row, cursor_col)
|
|
172
|
+
@search_state.find_next(cursor_row, cursor_col, buffer:)
|
|
170
173
|
else
|
|
171
|
-
@search_state.find_previous(cursor_row, cursor_col)
|
|
174
|
+
@search_state.find_previous(cursor_row, cursor_col, buffer:)
|
|
172
175
|
end
|
|
173
176
|
|
|
174
177
|
if match
|
|
@@ -18,6 +18,12 @@ module Mui
|
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def handle(key)
|
|
21
|
+
# Check plugin keymaps first (only when no pending motion)
|
|
22
|
+
unless @pending_motion
|
|
23
|
+
plugin_result = check_plugin_keymap(key, :visual)
|
|
24
|
+
return plugin_result if plugin_result
|
|
25
|
+
end
|
|
26
|
+
|
|
21
27
|
if @pending_motion
|
|
22
28
|
handle_pending_motion(key)
|
|
23
29
|
else
|
|
@@ -120,7 +126,7 @@ module Mui
|
|
|
120
126
|
if @selection.line_mode
|
|
121
127
|
change_lines(range)
|
|
122
128
|
else
|
|
123
|
-
|
|
129
|
+
undo_manager&.begin_group
|
|
124
130
|
change_range(range)
|
|
125
131
|
end
|
|
126
132
|
@pending_register = nil
|
|
@@ -154,16 +160,15 @@ module Mui
|
|
|
154
160
|
# Escape special regex characters for literal search
|
|
155
161
|
escaped_pattern = Regexp.escape(text)
|
|
156
162
|
|
|
157
|
-
# Set search state
|
|
163
|
+
# Set search state
|
|
158
164
|
search_state = @mode_manager.search_state
|
|
159
165
|
search_state.set_pattern(escaped_pattern, direction)
|
|
160
|
-
search_state.find_all_matches(buffer)
|
|
161
166
|
|
|
162
167
|
# Find next/previous match from current position
|
|
163
168
|
match = if direction == :forward
|
|
164
|
-
search_state.find_next(cursor_row, cursor_col)
|
|
169
|
+
search_state.find_next(cursor_row, cursor_col, buffer:)
|
|
165
170
|
else
|
|
166
|
-
search_state.find_previous(cursor_row, cursor_col)
|
|
171
|
+
search_state.find_previous(cursor_row, cursor_col, buffer:)
|
|
167
172
|
end
|
|
168
173
|
|
|
169
174
|
if match
|
|
@@ -207,7 +212,7 @@ module Mui
|
|
|
207
212
|
def change_lines(range)
|
|
208
213
|
lines = (range[:start_row]..range[:end_row]).map { |r| buffer.line(r) }
|
|
209
214
|
@register.delete(lines.join("\n"), linewise: true, name: @pending_register)
|
|
210
|
-
|
|
215
|
+
undo_manager&.begin_group
|
|
211
216
|
(range[:end_row] - range[:start_row] + 1).times do
|
|
212
217
|
buffer.delete_line(range[:start_row])
|
|
213
218
|
end
|
|
@@ -229,11 +234,11 @@ module Mui
|
|
|
229
234
|
def delete_lines(range)
|
|
230
235
|
lines = (range[:start_row]..range[:end_row]).map { |r| buffer.line(r) }
|
|
231
236
|
@register.delete(lines.join("\n"), linewise: true, name: @pending_register)
|
|
232
|
-
|
|
237
|
+
undo_manager&.begin_group unless undo_manager&.in_group?
|
|
233
238
|
(range[:end_row] - range[:start_row] + 1).times do
|
|
234
239
|
buffer.delete_line(range[:start_row])
|
|
235
240
|
end
|
|
236
|
-
|
|
241
|
+
undo_manager&.end_group
|
|
237
242
|
self.cursor_row = [range[:start_row], buffer.line_count - 1].min
|
|
238
243
|
self.cursor_col = 0
|
|
239
244
|
window.clamp_cursor_to_line(buffer)
|
|
@@ -268,7 +273,7 @@ module Mui
|
|
|
268
273
|
def indent_lines(start_row, end_row, direction)
|
|
269
274
|
indent_string = build_indent_string
|
|
270
275
|
|
|
271
|
-
|
|
276
|
+
undo_manager&.begin_group unless undo_manager&.in_group?
|
|
272
277
|
|
|
273
278
|
(start_row..end_row).each do |row|
|
|
274
279
|
if direction == :right
|
|
@@ -278,7 +283,7 @@ module Mui
|
|
|
278
283
|
end
|
|
279
284
|
end
|
|
280
285
|
|
|
281
|
-
|
|
286
|
+
undo_manager&.end_group
|
|
282
287
|
end
|
|
283
288
|
|
|
284
289
|
def build_indent_string
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "strscan"
|
|
4
|
+
|
|
5
|
+
module Mui
|
|
6
|
+
# Parser for Vim-style key notation strings
|
|
7
|
+
# Converts notation like "<Leader>gd", "<C-x><C-s>", "<Space>w" to internal key arrays
|
|
8
|
+
module KeyNotationParser
|
|
9
|
+
# Special key mappings (case-insensitive)
|
|
10
|
+
SPECIAL_KEYS = {
|
|
11
|
+
"space" => " ",
|
|
12
|
+
"tab" => "\t",
|
|
13
|
+
"cr" => "\r",
|
|
14
|
+
"enter" => "\r",
|
|
15
|
+
"return" => "\r",
|
|
16
|
+
"esc" => "\e",
|
|
17
|
+
"escape" => "\e",
|
|
18
|
+
"bs" => "\x7f",
|
|
19
|
+
"backspace" => "\x7f",
|
|
20
|
+
"del" => "\x7f",
|
|
21
|
+
"delete" => "\x7f",
|
|
22
|
+
"lt" => "<",
|
|
23
|
+
"gt" => ">",
|
|
24
|
+
"bar" => "|",
|
|
25
|
+
"bslash" => "\\",
|
|
26
|
+
"leader" => :leader
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
# Ctrl key mappings (a-z and some special characters)
|
|
30
|
+
CTRL_CHARS = {
|
|
31
|
+
"@" => 0, # NUL
|
|
32
|
+
"a" => 1,
|
|
33
|
+
"b" => 2,
|
|
34
|
+
"c" => 3,
|
|
35
|
+
"d" => 4,
|
|
36
|
+
"e" => 5,
|
|
37
|
+
"f" => 6,
|
|
38
|
+
"g" => 7,
|
|
39
|
+
"h" => 8, # Also backspace
|
|
40
|
+
"i" => 9, # Also tab
|
|
41
|
+
"j" => 10, # Also newline
|
|
42
|
+
"k" => 11,
|
|
43
|
+
"l" => 12,
|
|
44
|
+
"m" => 13, # Also carriage return
|
|
45
|
+
"n" => 14,
|
|
46
|
+
"o" => 15,
|
|
47
|
+
"p" => 16,
|
|
48
|
+
"q" => 17,
|
|
49
|
+
"r" => 18,
|
|
50
|
+
"s" => 19,
|
|
51
|
+
"t" => 20,
|
|
52
|
+
"u" => 21,
|
|
53
|
+
"v" => 22,
|
|
54
|
+
"w" => 23,
|
|
55
|
+
"x" => 24,
|
|
56
|
+
"y" => 25,
|
|
57
|
+
"z" => 26,
|
|
58
|
+
"[" => 27, # Also escape
|
|
59
|
+
"\\" => 28,
|
|
60
|
+
"]" => 29,
|
|
61
|
+
"^" => 30,
|
|
62
|
+
"_" => 31
|
|
63
|
+
}.freeze
|
|
64
|
+
|
|
65
|
+
class << self
|
|
66
|
+
# Parse a key notation string into an array of keys
|
|
67
|
+
# @param notation [String] Key notation (e.g., "<Leader>gd", "<C-x><C-s>")
|
|
68
|
+
# @return [Array<String, Symbol>] Array of normalized keys
|
|
69
|
+
def parse(notation)
|
|
70
|
+
return [] if notation.nil? || notation.empty?
|
|
71
|
+
|
|
72
|
+
tokens = []
|
|
73
|
+
scanner = StringScanner.new(notation)
|
|
74
|
+
|
|
75
|
+
until scanner.eos?
|
|
76
|
+
if scanner.scan(/<([^>]+)>/)
|
|
77
|
+
# Special key notation <...>
|
|
78
|
+
tokens << parse_special(scanner[1])
|
|
79
|
+
else
|
|
80
|
+
# Regular character
|
|
81
|
+
char = scanner.getch
|
|
82
|
+
tokens << char if char
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
tokens
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Parse a special key notation (content inside < >)
|
|
90
|
+
# @param name [String] Special key name (e.g., "C-x", "Leader", "Space")
|
|
91
|
+
# @return [String, Symbol] Normalized key
|
|
92
|
+
def parse_special(name)
|
|
93
|
+
return :leader if name.casecmp?("leader")
|
|
94
|
+
|
|
95
|
+
# Handle Ctrl key: <C-x>, <Ctrl-x>, <C-X>
|
|
96
|
+
return parse_ctrl_key(::Regexp.last_match(2)) if name =~ /\A(c|ctrl)-(.+)\z/i
|
|
97
|
+
|
|
98
|
+
# Handle Shift key: <S-x>, <Shift-x>
|
|
99
|
+
return parse_shift_key(::Regexp.last_match(2)) if name =~ /\A(s|shift)-(.+)\z/i
|
|
100
|
+
|
|
101
|
+
# Handle other special keys
|
|
102
|
+
normalized_name = name.downcase
|
|
103
|
+
SPECIAL_KEYS[normalized_name] || name
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Normalize an input key (from terminal) to internal representation
|
|
107
|
+
# @param key [Integer, String] Raw key input
|
|
108
|
+
# @return [String, nil] Normalized key string, or nil if invalid
|
|
109
|
+
def normalize_input_key(key)
|
|
110
|
+
case key
|
|
111
|
+
when String
|
|
112
|
+
key
|
|
113
|
+
when Integer
|
|
114
|
+
normalize_integer_key(key)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def parse_ctrl_key(char)
|
|
121
|
+
char_lower = char.downcase
|
|
122
|
+
code = CTRL_CHARS[char_lower]
|
|
123
|
+
code ? code.chr : char
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def parse_shift_key(char)
|
|
127
|
+
# Shift typically produces uppercase for letters
|
|
128
|
+
char.length == 1 ? char.upcase : char
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def normalize_integer_key(key)
|
|
132
|
+
case key
|
|
133
|
+
when KeyCode::ENTER_CR, KeyCode::ENTER_LF
|
|
134
|
+
"\r"
|
|
135
|
+
when KeyCode::ESCAPE
|
|
136
|
+
"\e"
|
|
137
|
+
when KeyCode::TAB
|
|
138
|
+
"\t"
|
|
139
|
+
when KeyCode::BACKSPACE
|
|
140
|
+
"\x7f"
|
|
141
|
+
when 0..31
|
|
142
|
+
# Control characters - convert to the character they represent
|
|
143
|
+
key.chr
|
|
144
|
+
when KeyCode::PRINTABLE_MIN..KeyCode::PRINTABLE_MAX
|
|
145
|
+
key.chr(Encoding::UTF_8)
|
|
146
|
+
end
|
|
147
|
+
rescue RangeError
|
|
148
|
+
nil
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mui
|
|
4
|
+
# Represents a sequence of keys for key mapping
|
|
5
|
+
# Parses Vim-style notation and provides normalization for matching
|
|
6
|
+
class KeySequence
|
|
7
|
+
attr_reader :keys, :notation
|
|
8
|
+
|
|
9
|
+
# @param notation [String] Key notation string (e.g., "<Leader>gd", "<C-x><C-s>")
|
|
10
|
+
def initialize(notation)
|
|
11
|
+
@notation = notation
|
|
12
|
+
@keys = KeyNotationParser.parse(notation)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Normalize the key sequence by expanding :leader to actual leader key
|
|
16
|
+
# @param leader_key [String] The actual leader key (e.g., "\\", " ")
|
|
17
|
+
# @return [Array<String>] Array of normalized key strings
|
|
18
|
+
def normalize(leader_key)
|
|
19
|
+
@keys.map { |k| k == :leader ? leader_key : k }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Get the length of the key sequence
|
|
23
|
+
# @return [Integer] Number of keys in the sequence
|
|
24
|
+
def length
|
|
25
|
+
@keys.length
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Check if this sequence contains a leader key
|
|
29
|
+
# @return [Boolean]
|
|
30
|
+
def leader?
|
|
31
|
+
@keys.include?(:leader)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Check if this is a single key sequence
|
|
35
|
+
# @return [Boolean]
|
|
36
|
+
def single_key?
|
|
37
|
+
@keys.length == 1
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Check equality with another KeySequence
|
|
41
|
+
# @param other [KeySequence]
|
|
42
|
+
# @return [Boolean]
|
|
43
|
+
def ==(other)
|
|
44
|
+
return false unless other.is_a?(KeySequence)
|
|
45
|
+
|
|
46
|
+
@keys == other.keys
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
alias eql? ==
|
|
50
|
+
|
|
51
|
+
# Hash for use in Hash keys
|
|
52
|
+
def hash
|
|
53
|
+
@keys.hash
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Convert back to notation string for display
|
|
57
|
+
# @return [String]
|
|
58
|
+
def to_s
|
|
59
|
+
@notation
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Inspect for debugging
|
|
63
|
+
def inspect
|
|
64
|
+
"#<Mui::KeySequence #{@notation.inspect} => #{@keys.inspect}>"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mui
|
|
4
|
+
# Buffer for accumulating key inputs for multi-key sequence matching
|
|
5
|
+
# Tracks timing for timeout detection
|
|
6
|
+
class KeySequenceBuffer
|
|
7
|
+
attr_reader :last_input_time
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@buffer = []
|
|
11
|
+
@last_input_time = nil
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Push a key into the buffer
|
|
15
|
+
# @param key [Integer, String] Raw key input from terminal
|
|
16
|
+
# @return [Boolean] true if key was added, false if key was invalid
|
|
17
|
+
def push(key)
|
|
18
|
+
normalized = KeyNotationParser.normalize_input_key(key)
|
|
19
|
+
return false unless normalized
|
|
20
|
+
|
|
21
|
+
@buffer << normalized
|
|
22
|
+
@last_input_time = Time.now
|
|
23
|
+
true
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Clear the buffer
|
|
27
|
+
def clear
|
|
28
|
+
@buffer.clear
|
|
29
|
+
@last_input_time = nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Check if buffer is empty
|
|
33
|
+
# @return [Boolean]
|
|
34
|
+
def empty?
|
|
35
|
+
@buffer.empty?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Get buffer length
|
|
39
|
+
# @return [Integer]
|
|
40
|
+
def length
|
|
41
|
+
@buffer.length
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
alias size length
|
|
45
|
+
|
|
46
|
+
# Get copy of buffer contents as array
|
|
47
|
+
# @return [Array<String>]
|
|
48
|
+
def to_a
|
|
49
|
+
@buffer.dup
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Get the first key in the buffer
|
|
53
|
+
# @return [String, nil]
|
|
54
|
+
def first
|
|
55
|
+
@buffer.first
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Remove and return the first key
|
|
59
|
+
# @return [String, nil]
|
|
60
|
+
def shift
|
|
61
|
+
key = @buffer.shift
|
|
62
|
+
@last_input_time = nil if @buffer.empty?
|
|
63
|
+
key
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Check if the buffer has timed out
|
|
67
|
+
# @param timeout_ms [Integer] Timeout in milliseconds
|
|
68
|
+
# @return [Boolean]
|
|
69
|
+
def timeout?(timeout_ms)
|
|
70
|
+
return false unless @last_input_time
|
|
71
|
+
return false if @buffer.empty?
|
|
72
|
+
|
|
73
|
+
elapsed_ms = (Time.now - @last_input_time) * 1000
|
|
74
|
+
elapsed_ms > timeout_ms
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Get elapsed time since last input in milliseconds
|
|
78
|
+
# @return [Float, nil] Elapsed time in ms, or nil if no input
|
|
79
|
+
def elapsed_ms
|
|
80
|
+
return nil unless @last_input_time
|
|
81
|
+
|
|
82
|
+
(Time.now - @last_input_time) * 1000
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mui
|
|
4
|
+
# Main handler for multi-key sequence processing
|
|
5
|
+
# Integrates buffer, matcher, and timeout handling
|
|
6
|
+
class KeySequenceHandler
|
|
7
|
+
DEFAULT_TIMEOUT_MS = 1000
|
|
8
|
+
|
|
9
|
+
# Process result types
|
|
10
|
+
RESULT_HANDLED = :handled # Handler executed
|
|
11
|
+
RESULT_PENDING = :pending # Waiting for more keys
|
|
12
|
+
RESULT_PASSTHROUGH = :passthrough # No match, pass key to built-in handler
|
|
13
|
+
|
|
14
|
+
attr_reader :buffer
|
|
15
|
+
|
|
16
|
+
# @param config [Config] Configuration object
|
|
17
|
+
def initialize(config)
|
|
18
|
+
@config = config
|
|
19
|
+
@buffer = KeySequenceBuffer.new
|
|
20
|
+
@keymaps = {} # { mode => { KeySequence => handler } }
|
|
21
|
+
@pending_handler = nil # Handler for exact match while waiting for longer
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Get the leader key from config
|
|
25
|
+
# @return [String]
|
|
26
|
+
def leader_key
|
|
27
|
+
@config.get(:leader) || "\\"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Get the timeout in milliseconds
|
|
31
|
+
# @return [Integer]
|
|
32
|
+
def timeout_ms
|
|
33
|
+
@config.get(:timeoutlen) || DEFAULT_TIMEOUT_MS
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Register a key sequence mapping
|
|
37
|
+
# @param mode [Symbol] Mode (:normal, :insert, etc.)
|
|
38
|
+
# @param key_notation [String] Key notation (e.g., "<Leader>gd")
|
|
39
|
+
# @param handler [Proc] Handler to execute
|
|
40
|
+
def register(mode, key_notation, handler)
|
|
41
|
+
@keymaps[mode] ||= {}
|
|
42
|
+
sequence = KeySequence.new(key_notation)
|
|
43
|
+
@keymaps[mode][sequence] = handler
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Process an input key
|
|
47
|
+
# @param key [Integer, String] Raw key input
|
|
48
|
+
# @param mode [Symbol] Current mode
|
|
49
|
+
# @return [Array<Symbol, Object>] [result_type, data]
|
|
50
|
+
# - [:handled, handler] - Execute the handler
|
|
51
|
+
# - [:pending, nil] - Wait for more input
|
|
52
|
+
# - [:passthrough, key] - Pass key to built-in handler
|
|
53
|
+
def process(key, mode)
|
|
54
|
+
# Check timeout first - if timed out, handle before processing new key
|
|
55
|
+
if @buffer.timeout?(timeout_ms) && !@buffer.empty?
|
|
56
|
+
result = handle_timeout(mode)
|
|
57
|
+
# If we got a result, return it; the new key will be processed next time
|
|
58
|
+
return result if result[0] == RESULT_HANDLED
|
|
59
|
+
|
|
60
|
+
# If passthrough, clear buffer and continue with new key
|
|
61
|
+
@buffer.clear
|
|
62
|
+
@pending_handler = nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Add key to buffer
|
|
66
|
+
unless @buffer.push(key)
|
|
67
|
+
# Invalid key, pass through as-is
|
|
68
|
+
return [RESULT_PASSTHROUGH, key]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Match against keymaps
|
|
72
|
+
matcher = KeySequenceMatcher.new(@keymaps, leader_key)
|
|
73
|
+
match_type, handler = matcher.match(mode, @buffer.to_a)
|
|
74
|
+
|
|
75
|
+
case match_type
|
|
76
|
+
when KeySequenceMatcher::MATCH_EXACT
|
|
77
|
+
has_longer = matcher.longer_sequences?(mode, @buffer.to_a)
|
|
78
|
+
if has_longer
|
|
79
|
+
# Exact match but longer sequences exist
|
|
80
|
+
# Store handler and wait for more input or timeout
|
|
81
|
+
@pending_handler = handler
|
|
82
|
+
[RESULT_PENDING, nil]
|
|
83
|
+
else
|
|
84
|
+
# Exact match, no longer sequences - execute immediately
|
|
85
|
+
@buffer.clear
|
|
86
|
+
@pending_handler = nil
|
|
87
|
+
[RESULT_HANDLED, handler]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
when KeySequenceMatcher::MATCH_PARTIAL
|
|
91
|
+
# Could become a match, wait for more input
|
|
92
|
+
[RESULT_PENDING, nil]
|
|
93
|
+
|
|
94
|
+
else
|
|
95
|
+
# No match - pass through first key
|
|
96
|
+
handle_no_match
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Check for timeout and handle if needed (called from main loop)
|
|
101
|
+
# @param mode [Symbol] Current mode
|
|
102
|
+
# @return [Array<Symbol, Object>, nil] Result if timed out, nil otherwise
|
|
103
|
+
def check_timeout(mode)
|
|
104
|
+
return nil if @buffer.empty?
|
|
105
|
+
return nil unless @buffer.timeout?(timeout_ms)
|
|
106
|
+
|
|
107
|
+
handle_timeout(mode)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Check if there are pending keys in the buffer
|
|
111
|
+
# @return [Boolean]
|
|
112
|
+
def pending?
|
|
113
|
+
!@buffer.empty?
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Clear the buffer and pending state
|
|
117
|
+
def clear
|
|
118
|
+
@buffer.clear
|
|
119
|
+
@pending_handler = nil
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Get pending key display for status line
|
|
123
|
+
# @return [String, nil]
|
|
124
|
+
def pending_keys_display
|
|
125
|
+
return nil if @buffer.empty?
|
|
126
|
+
|
|
127
|
+
@buffer.to_a.join
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Rebuild internal keymaps from config
|
|
131
|
+
# Called after config changes
|
|
132
|
+
def rebuild_keymaps
|
|
133
|
+
@keymaps = {}
|
|
134
|
+
@config.keymaps.each do |mode, mappings|
|
|
135
|
+
mappings.each do |key_notation, handler|
|
|
136
|
+
register(mode, key_notation, handler)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
def handle_timeout(_mode)
|
|
144
|
+
if @pending_handler
|
|
145
|
+
# We had an exact match, execute it
|
|
146
|
+
handler = @pending_handler
|
|
147
|
+
@buffer.clear
|
|
148
|
+
@pending_handler = nil
|
|
149
|
+
[RESULT_HANDLED, handler]
|
|
150
|
+
else
|
|
151
|
+
# No exact match, passthrough first key
|
|
152
|
+
handle_no_match
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def handle_no_match
|
|
157
|
+
first_key = @buffer.shift
|
|
158
|
+
@buffer.clear
|
|
159
|
+
@pending_handler = nil
|
|
160
|
+
[RESULT_PASSTHROUGH, first_key]
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|