mui 0.2.0 → 0.4.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 +18 -10
- data/CHANGELOG.md +162 -0
- data/README.md +309 -6
- data/docs/_config.yml +56 -0
- data/docs/configuration.md +314 -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 +155 -0
- data/lib/mui/color_manager.rb +140 -6
- data/lib/mui/color_scheme.rb +1 -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 +90 -2
- data/lib/mui/floating_window.rb +53 -1
- data/lib/mui/handler_result.rb +13 -7
- data/lib/mui/highlighters/search_highlighter.rb +2 -1
- data/lib/mui/highlighters/syntax_highlighter.rb +4 -1
- data/lib/mui/insert_completion_state.rb +15 -2
- 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 +159 -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 +30 -6
- data/lib/mui/search_state.rb +74 -27
- data/lib/mui/syntax/language_detector.rb +33 -1
- data/lib/mui/syntax/lexers/c_lexer.rb +2 -0
- data/lib/mui/syntax/lexers/css_lexer.rb +121 -0
- data/lib/mui/syntax/lexers/go_lexer.rb +207 -0
- data/lib/mui/syntax/lexers/html_lexer.rb +118 -0
- data/lib/mui/syntax/lexers/javascript_lexer.rb +219 -0
- data/lib/mui/syntax/lexers/markdown_lexer.rb +210 -0
- data/lib/mui/syntax/lexers/ruby_lexer.rb +3 -0
- data/lib/mui/syntax/lexers/rust_lexer.rb +150 -0
- data/lib/mui/syntax/lexers/typescript_lexer.rb +225 -0
- data/lib/mui/terminal_adapter/base.rb +21 -0
- data/lib/mui/terminal_adapter/curses.rb +37 -11
- data/lib/mui/themes/default.rb +263 -132
- data/lib/mui/version.rb +1 -1
- data/lib/mui/window.rb +105 -39
- 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
|
@@ -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
|
data/lib/mui/command_line.rb
CHANGED
|
@@ -5,11 +5,12 @@ module Mui
|
|
|
5
5
|
# Commands that accept file path arguments
|
|
6
6
|
FILE_COMMANDS = %w[e w sp split vs vsplit tabnew tabe tabedit].freeze
|
|
7
7
|
|
|
8
|
-
attr_reader :buffer, :cursor_pos
|
|
8
|
+
attr_reader :buffer, :cursor_pos, :history
|
|
9
9
|
|
|
10
|
-
def initialize
|
|
10
|
+
def initialize(history: CommandHistory.new)
|
|
11
11
|
@buffer = ""
|
|
12
12
|
@cursor_pos = 0
|
|
13
|
+
@history = history
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
def input(char)
|
|
@@ -27,6 +28,25 @@ module Mui
|
|
|
27
28
|
def clear
|
|
28
29
|
@buffer = ""
|
|
29
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
|
|
30
50
|
end
|
|
31
51
|
|
|
32
52
|
def move_cursor_left
|
|
@@ -38,6 +58,9 @@ module Mui
|
|
|
38
58
|
end
|
|
39
59
|
|
|
40
60
|
def execute
|
|
61
|
+
command = @buffer.strip
|
|
62
|
+
@history.add(command) unless command.empty?
|
|
63
|
+
@history.reset
|
|
41
64
|
result = parse(@buffer)
|
|
42
65
|
@buffer = ""
|
|
43
66
|
@cursor_pos = 0
|
|
@@ -127,6 +150,13 @@ module Mui
|
|
|
127
150
|
{ action: :tab_move, position: ::Regexp.last_match(1).to_i }
|
|
128
151
|
when /^(\d+)tabn(?:ext)?/, /^tabn(?:ext)?\s+(\d+)/
|
|
129
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
|
|
130
160
|
when /^(\d+)$/
|
|
131
161
|
{ action: :goto_line, line_number: ::Regexp.last_match(1).to_i }
|
|
132
162
|
else
|
data/lib/mui/command_registry.rb
CHANGED
|
@@ -12,14 +12,33 @@ module Mui
|
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
def execute(name, context, *)
|
|
15
|
-
command =
|
|
15
|
+
command = find(name)
|
|
16
16
|
raise UnknownCommandError, name unless command
|
|
17
17
|
|
|
18
18
|
command.call(context, *)
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
def exists?(name)
|
|
22
|
-
@commands.key?(name.to_sym)
|
|
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
|
|
23
42
|
end
|
|
24
43
|
end
|
|
25
44
|
end
|
data/lib/mui/config.rb
CHANGED
|
@@ -11,7 +11,9 @@ module Mui
|
|
|
11
11
|
shiftwidth: 2, # Indent width for > and < commands
|
|
12
12
|
expandtab: true, # Use spaces instead of tabs
|
|
13
13
|
tabstop: 8, # Tab display width
|
|
14
|
-
reselect_after_indent: false # Keep selection after > / < in visual mode
|
|
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
|
|
15
17
|
}
|
|
16
18
|
@plugins = []
|
|
17
19
|
@keymaps = {}
|
data/lib/mui/editor.rb
CHANGED
|
@@ -4,7 +4,7 @@ module Mui
|
|
|
4
4
|
# Main editor class that coordinates all components
|
|
5
5
|
class Editor
|
|
6
6
|
attr_reader :tab_manager, :undo_manager, :autocmd, :command_registry, :job_manager, :color_scheme, :floating_window,
|
|
7
|
-
:insert_completion_state
|
|
7
|
+
:insert_completion_state, :key_sequence_handler
|
|
8
8
|
attr_accessor :message, :running
|
|
9
9
|
|
|
10
10
|
def initialize(file_path = nil, adapter: TerminalAdapter::Curses.new, load_config: true)
|
|
@@ -46,12 +46,17 @@ module Mui
|
|
|
46
46
|
# Load plugin autocmds
|
|
47
47
|
load_plugin_autocmds
|
|
48
48
|
|
|
49
|
+
# Initialize key sequence handler for multi-key mappings
|
|
50
|
+
@key_sequence_handler = KeySequenceHandler.new(Mui.config)
|
|
51
|
+
@key_sequence_handler.rebuild_keymaps
|
|
52
|
+
|
|
49
53
|
@mode_manager = ModeManager.new(
|
|
50
54
|
window: @tab_manager,
|
|
51
55
|
buffer: @buffer,
|
|
52
56
|
command_line: @command_line,
|
|
53
57
|
undo_manager: @undo_manager,
|
|
54
|
-
editor: self
|
|
58
|
+
editor: self,
|
|
59
|
+
key_sequence_handler: @key_sequence_handler
|
|
55
60
|
)
|
|
56
61
|
|
|
57
62
|
# Trigger BufEnter event
|
|
@@ -85,6 +90,7 @@ module Mui
|
|
|
85
90
|
def run
|
|
86
91
|
while @running
|
|
87
92
|
process_job_results
|
|
93
|
+
check_key_sequence_timeout
|
|
88
94
|
update_window_size
|
|
89
95
|
render
|
|
90
96
|
key = @input.read_nonblock
|
|
@@ -108,6 +114,42 @@ module Mui
|
|
|
108
114
|
window_manager.split_horizontal(scratch_buffer)
|
|
109
115
|
end
|
|
110
116
|
|
|
117
|
+
# Update existing scratch buffer or create new one
|
|
118
|
+
def update_or_create_scratch_buffer(name, content)
|
|
119
|
+
existing = find_scratch_buffer(name)
|
|
120
|
+
|
|
121
|
+
if existing
|
|
122
|
+
existing.content = content
|
|
123
|
+
focus_buffer(existing)
|
|
124
|
+
else
|
|
125
|
+
open_scratch_buffer(name, content)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Find a scratch buffer by name across all tabs and windows
|
|
130
|
+
def find_scratch_buffer(name)
|
|
131
|
+
@tab_manager.tabs.each do |tab|
|
|
132
|
+
tab.window_manager.windows.each do |win|
|
|
133
|
+
return win.buffer if win.buffer.name == name && win.buffer.readonly?
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
nil
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Focus a specific buffer by switching to its window
|
|
140
|
+
def focus_buffer(target_buffer)
|
|
141
|
+
@tab_manager.tabs.each_with_index do |tab, tab_index|
|
|
142
|
+
tab.window_manager.windows.each do |win|
|
|
143
|
+
next unless win.buffer == target_buffer
|
|
144
|
+
|
|
145
|
+
@tab_manager.go_to(tab_index)
|
|
146
|
+
tab.window_manager.focus_window(win)
|
|
147
|
+
return true
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
false
|
|
151
|
+
end
|
|
152
|
+
|
|
111
153
|
# Suspend UI for running external interactive commands (e.g., fzf)
|
|
112
154
|
def suspend_ui
|
|
113
155
|
@adapter.suspend
|
|
@@ -196,8 +238,20 @@ module Mui
|
|
|
196
238
|
end
|
|
197
239
|
|
|
198
240
|
def render
|
|
241
|
+
# Force complete redraw if floating window or completion popup was closed
|
|
242
|
+
# This is needed because multibyte characters (CJK) can be corrupted
|
|
243
|
+
# when partially overwritten by popups
|
|
244
|
+
if @floating_window.needs_clear? || @insert_completion_state.needs_clear?
|
|
245
|
+
@screen.touchwin
|
|
246
|
+
@insert_completion_state.clear_needs_clear
|
|
247
|
+
end
|
|
248
|
+
|
|
199
249
|
@screen.clear
|
|
200
250
|
|
|
251
|
+
# Clear the area where the floating window was previously displayed
|
|
252
|
+
# Must be done before window rendering to avoid overwriting text
|
|
253
|
+
@floating_window.clear_last_bounds(@screen) if @floating_window.needs_clear?
|
|
254
|
+
|
|
201
255
|
@tab_bar_renderer.render(@screen, 0)
|
|
202
256
|
|
|
203
257
|
window.ensure_cursor_visible
|
|
@@ -315,5 +369,39 @@ module Mui
|
|
|
315
369
|
def process_job_results
|
|
316
370
|
@job_manager.poll
|
|
317
371
|
end
|
|
372
|
+
|
|
373
|
+
def check_key_sequence_timeout
|
|
374
|
+
return unless @key_sequence_handler.pending?
|
|
375
|
+
|
|
376
|
+
mode_symbol = mode_to_symbol(@mode_manager.mode)
|
|
377
|
+
result = @key_sequence_handler.check_timeout(mode_symbol)
|
|
378
|
+
|
|
379
|
+
return unless result
|
|
380
|
+
|
|
381
|
+
type, data = result
|
|
382
|
+
case type
|
|
383
|
+
when KeySequenceHandler::RESULT_HANDLED
|
|
384
|
+
execute_keymap_handler(data)
|
|
385
|
+
when KeySequenceHandler::RESULT_PASSTHROUGH
|
|
386
|
+
# Re-process the passthrough key
|
|
387
|
+
handle_key(data) if data
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def mode_to_symbol(mode)
|
|
392
|
+
case mode
|
|
393
|
+
when Mode::NORMAL then :normal
|
|
394
|
+
when Mode::INSERT then :insert
|
|
395
|
+
when Mode::VISUAL, Mode::VISUAL_LINE then :visual
|
|
396
|
+
when Mode::COMMAND then :command
|
|
397
|
+
when Mode::SEARCH_FORWARD, Mode::SEARCH_BACKWARD then :search
|
|
398
|
+
else :normal
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def execute_keymap_handler(handler)
|
|
403
|
+
context = CommandContext.new(editor: self, buffer:, window:)
|
|
404
|
+
handler.call(context)
|
|
405
|
+
end
|
|
318
406
|
end
|
|
319
407
|
end
|
data/lib/mui/floating_window.rb
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Mui
|
|
4
4
|
# A floating window (popup) for displaying temporary content like hover info
|
|
5
5
|
class FloatingWindow
|
|
6
|
-
attr_reader :content, :row, :col, :width, :height
|
|
6
|
+
attr_reader :content, :row, :col, :width, :height, :last_bounds
|
|
7
7
|
attr_accessor :visible
|
|
8
8
|
|
|
9
9
|
def initialize(color_scheme)
|
|
@@ -15,6 +15,8 @@ module Mui
|
|
|
15
15
|
@height = 0
|
|
16
16
|
@visible = false
|
|
17
17
|
@scroll_offset = 0
|
|
18
|
+
@last_bounds = nil
|
|
19
|
+
@needs_clear = false
|
|
18
20
|
end
|
|
19
21
|
|
|
20
22
|
# Show the floating window with content at the specified position
|
|
@@ -27,14 +29,49 @@ module Mui
|
|
|
27
29
|
@scroll_offset = 0
|
|
28
30
|
calculate_dimensions
|
|
29
31
|
@visible = true
|
|
32
|
+
@needs_clear = false
|
|
30
33
|
end
|
|
31
34
|
|
|
32
35
|
# Hide the floating window
|
|
33
36
|
def hide
|
|
37
|
+
return unless @visible
|
|
38
|
+
|
|
39
|
+
# Record bounds for clearing on next render
|
|
40
|
+
@last_bounds = {
|
|
41
|
+
row: @row,
|
|
42
|
+
col: @col,
|
|
43
|
+
width: @width,
|
|
44
|
+
height: @height
|
|
45
|
+
}
|
|
46
|
+
@needs_clear = true
|
|
34
47
|
@visible = false
|
|
35
48
|
@content = []
|
|
36
49
|
end
|
|
37
50
|
|
|
51
|
+
# Check if the previous window area needs to be cleared
|
|
52
|
+
def needs_clear?
|
|
53
|
+
@needs_clear && @last_bounds
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Clear the area where the floating window was previously displayed
|
|
57
|
+
def clear_last_bounds(screen)
|
|
58
|
+
return unless needs_clear?
|
|
59
|
+
|
|
60
|
+
bounds = @last_bounds
|
|
61
|
+
adjusted_row, adjusted_col = adjust_position_for_bounds(screen, bounds)
|
|
62
|
+
|
|
63
|
+
bounds[:height].times do |i|
|
|
64
|
+
row = adjusted_row + i
|
|
65
|
+
next if row.negative? || row >= screen.height
|
|
66
|
+
|
|
67
|
+
spaces = " " * bounds[:width]
|
|
68
|
+
screen.put(row, adjusted_col, spaces)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
@needs_clear = false
|
|
72
|
+
@last_bounds = nil
|
|
73
|
+
end
|
|
74
|
+
|
|
38
75
|
# Scroll content up
|
|
39
76
|
def scroll_up
|
|
40
77
|
@scroll_offset = [@scroll_offset - 1, 0].max if @visible
|
|
@@ -105,6 +142,21 @@ module Mui
|
|
|
105
142
|
[row, col]
|
|
106
143
|
end
|
|
107
144
|
|
|
145
|
+
def adjust_position_for_bounds(screen, bounds)
|
|
146
|
+
row = bounds[:row]
|
|
147
|
+
col = bounds[:col]
|
|
148
|
+
|
|
149
|
+
# Adjust horizontal position
|
|
150
|
+
col = screen.width - bounds[:width] if col + bounds[:width] > screen.width
|
|
151
|
+
col = [col, 0].max
|
|
152
|
+
|
|
153
|
+
# Adjust vertical position
|
|
154
|
+
row = bounds[:row] - bounds[:height] if row + bounds[:height] > screen.height
|
|
155
|
+
row = [row, 0].max
|
|
156
|
+
|
|
157
|
+
[row, col]
|
|
158
|
+
end
|
|
159
|
+
|
|
108
160
|
def draw_border(screen, row, col)
|
|
109
161
|
style = @color_scheme[:floating_window] || @color_scheme[:completion_popup]
|
|
110
162
|
|
data/lib/mui/handler_result.rb
CHANGED
|
@@ -6,10 +6,11 @@ module Mui
|
|
|
6
6
|
class Base
|
|
7
7
|
attr_reader :mode, :message
|
|
8
8
|
|
|
9
|
-
def initialize(mode: nil, message: nil, quit: false)
|
|
9
|
+
def initialize(mode: nil, message: nil, quit: false, pending_sequence: false)
|
|
10
10
|
@mode = mode
|
|
11
11
|
@message = message
|
|
12
12
|
@quit = quit
|
|
13
|
+
@pending_sequence = pending_sequence
|
|
13
14
|
freeze
|
|
14
15
|
end
|
|
15
16
|
|
|
@@ -17,6 +18,11 @@ module Mui
|
|
|
17
18
|
@quit
|
|
18
19
|
end
|
|
19
20
|
|
|
21
|
+
# True when waiting for more keys in a multi-key sequence
|
|
22
|
+
def pending_sequence?
|
|
23
|
+
@pending_sequence
|
|
24
|
+
end
|
|
25
|
+
|
|
20
26
|
def start_selection?
|
|
21
27
|
false
|
|
22
28
|
end
|
|
@@ -36,11 +42,11 @@ module Mui
|
|
|
36
42
|
|
|
37
43
|
# Result for NormalMode - handles visual mode start
|
|
38
44
|
class NormalModeResult < Base
|
|
39
|
-
def initialize(mode: nil, message: nil, quit: false, start_selection: false, line_mode: false, group_started: false)
|
|
45
|
+
def initialize(mode: nil, message: nil, quit: false, pending_sequence: false, start_selection: false, line_mode: false, group_started: false)
|
|
40
46
|
@start_selection = start_selection
|
|
41
47
|
@line_mode = line_mode
|
|
42
48
|
@group_started = group_started
|
|
43
|
-
super(mode:, message:, quit:)
|
|
49
|
+
super(mode:, message:, quit:, pending_sequence:)
|
|
44
50
|
end
|
|
45
51
|
|
|
46
52
|
def start_selection?
|
|
@@ -58,11 +64,11 @@ module Mui
|
|
|
58
64
|
|
|
59
65
|
# Result for VisualMode - handles selection clear and line mode toggle
|
|
60
66
|
class VisualModeResult < Base
|
|
61
|
-
def initialize(mode: nil, message: nil, quit: false, clear_selection: false, toggle_line_mode: false, group_started: false)
|
|
67
|
+
def initialize(mode: nil, message: nil, quit: false, pending_sequence: false, clear_selection: false, toggle_line_mode: false, group_started: false)
|
|
62
68
|
@clear_selection = clear_selection
|
|
63
69
|
@toggle_line_mode = toggle_line_mode
|
|
64
70
|
@group_started = group_started
|
|
65
|
-
super(mode:, message:, quit:)
|
|
71
|
+
super(mode:, message:, quit:, pending_sequence:)
|
|
66
72
|
end
|
|
67
73
|
|
|
68
74
|
def clear_selection?
|
|
@@ -88,9 +94,9 @@ module Mui
|
|
|
88
94
|
|
|
89
95
|
# Result for SearchMode - handles search execution
|
|
90
96
|
class SearchModeResult < Base
|
|
91
|
-
def initialize(mode: nil, message: nil, quit: false, cancelled: false)
|
|
97
|
+
def initialize(mode: nil, message: nil, quit: false, pending_sequence: false, cancelled: false)
|
|
92
98
|
@cancelled = cancelled
|
|
93
|
-
super(mode:, message:, quit:)
|
|
99
|
+
super(mode:, message:, quit:, pending_sequence:)
|
|
94
100
|
end
|
|
95
101
|
|
|
96
102
|
def cancelled?
|
|
@@ -5,9 +5,10 @@ module Mui
|
|
|
5
5
|
class SearchHighlighter < Base
|
|
6
6
|
def highlights_for(row, _line, options = {})
|
|
7
7
|
search_state = options[:search_state]
|
|
8
|
+
buffer = options[:buffer]
|
|
8
9
|
return [] unless search_state&.has_pattern?
|
|
9
10
|
|
|
10
|
-
matches = search_state.matches_for_row(row)
|
|
11
|
+
matches = search_state.matches_for_row(row, buffer:)
|
|
11
12
|
matches.map do |match|
|
|
12
13
|
Highlight.new(
|
|
13
14
|
start_col: match[:col],
|
|
@@ -19,7 +19,10 @@ module Mui
|
|
|
19
19
|
instance_variable: :syntax_instance_variable,
|
|
20
20
|
global_variable: :syntax_global_variable,
|
|
21
21
|
method_call: :syntax_method_call,
|
|
22
|
-
|
|
22
|
+
function_definition: :syntax_function_definition,
|
|
23
|
+
type: :syntax_type,
|
|
24
|
+
macro: :syntax_keyword, # Rust macros (println!, vec!, etc.)
|
|
25
|
+
regex: :syntax_string # JavaScript/TypeScript regex literals
|
|
23
26
|
}.freeze
|
|
24
27
|
|
|
25
28
|
def initialize(color_scheme, buffer: nil)
|
|
@@ -6,16 +6,29 @@ module Mui
|
|
|
6
6
|
attr_reader :items, :selected_index, :prefix, :original_items
|
|
7
7
|
|
|
8
8
|
def initialize
|
|
9
|
-
|
|
9
|
+
@needs_clear = false
|
|
10
|
+
reset(set_needs_clear: false)
|
|
10
11
|
end
|
|
11
12
|
|
|
12
|
-
def reset
|
|
13
|
+
def reset(set_needs_clear: true)
|
|
14
|
+
# Set needs_clear flag if we had items (popup was visible)
|
|
15
|
+
@needs_clear = true if set_needs_clear && !@items.empty?
|
|
13
16
|
@items = []
|
|
14
17
|
@original_items = []
|
|
15
18
|
@selected_index = 0
|
|
16
19
|
@prefix = ""
|
|
17
20
|
end
|
|
18
21
|
|
|
22
|
+
# Check if the previous popup area needs to be cleared
|
|
23
|
+
def needs_clear?
|
|
24
|
+
@needs_clear
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Clear the needs_clear flag after redraw
|
|
28
|
+
def clear_needs_clear
|
|
29
|
+
@needs_clear = false
|
|
30
|
+
end
|
|
31
|
+
|
|
19
32
|
def active?
|
|
20
33
|
!@items.empty?
|
|
21
34
|
end
|
data/lib/mui/key_handler/base.rb
CHANGED
|
@@ -27,13 +27,100 @@ module Mui
|
|
|
27
27
|
@mode_manager&.editor
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
+
# Returns the current buffer's undo_manager for dynamic access
|
|
31
|
+
# This ensures undo/redo works correctly when buffer changes (e.g., via :e)
|
|
32
|
+
def undo_manager
|
|
33
|
+
buffer&.undo_manager
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Access to key sequence handler for multi-key mappings
|
|
37
|
+
def key_sequence_handler
|
|
38
|
+
@mode_manager&.key_sequence_handler
|
|
39
|
+
end
|
|
40
|
+
|
|
30
41
|
# Handle a key input
|
|
31
42
|
def handle(_key)
|
|
32
43
|
raise MethodNotOverriddenError, "Subclasses must orverride #handle"
|
|
33
44
|
end
|
|
34
45
|
|
|
46
|
+
# Check plugin keymap with multi-key sequence support
|
|
47
|
+
# @param key [Integer, String] Raw key input
|
|
48
|
+
# @param mode_symbol [Symbol] Mode symbol (:normal, :insert, :visual, :command)
|
|
49
|
+
# @return [HandlerResult, nil] Result if handled or pending, nil to continue with built-in
|
|
50
|
+
def check_plugin_keymap(key, mode_symbol)
|
|
51
|
+
return nil unless key_sequence_handler
|
|
52
|
+
|
|
53
|
+
type, data = key_sequence_handler.process(key, mode_symbol)
|
|
54
|
+
|
|
55
|
+
case type
|
|
56
|
+
when KeySequenceHandler::RESULT_HANDLED
|
|
57
|
+
execute_plugin_handler(data, mode_symbol)
|
|
58
|
+
when KeySequenceHandler::RESULT_PENDING
|
|
59
|
+
# Return a result that tells the handler to wait
|
|
60
|
+
# Use base class directly since subclasses may not support pending_sequence
|
|
61
|
+
HandlerResult::Base.new(pending_sequence: true)
|
|
62
|
+
when KeySequenceHandler::RESULT_PASSTHROUGH
|
|
63
|
+
# Check for single-key keymap (backward compatibility)
|
|
64
|
+
check_single_key_keymap(data, mode_symbol)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
35
68
|
private
|
|
36
69
|
|
|
70
|
+
# Check single-key keymap for backward compatibility
|
|
71
|
+
def check_single_key_keymap(key, mode_symbol)
|
|
72
|
+
key_str = convert_key_to_string(key)
|
|
73
|
+
return nil unless key_str
|
|
74
|
+
|
|
75
|
+
plugin_handler = Mui.config.keymaps[mode_symbol]&.[](key_str)
|
|
76
|
+
return nil unless plugin_handler
|
|
77
|
+
|
|
78
|
+
execute_plugin_handler(plugin_handler, mode_symbol)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Execute a plugin handler and wrap result
|
|
82
|
+
def execute_plugin_handler(handler, mode_symbol)
|
|
83
|
+
return nil unless @mode_manager&.editor
|
|
84
|
+
|
|
85
|
+
context = CommandContext.new(
|
|
86
|
+
editor: @mode_manager.editor,
|
|
87
|
+
buffer:,
|
|
88
|
+
window:
|
|
89
|
+
)
|
|
90
|
+
handler_result = handler.call(context)
|
|
91
|
+
|
|
92
|
+
return nil unless handler_result
|
|
93
|
+
|
|
94
|
+
wrap_handler_result(handler_result, mode_symbol)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Wrap handler result in appropriate type
|
|
98
|
+
def wrap_handler_result(handler_result, _mode_symbol)
|
|
99
|
+
return handler_result if handler_result.is_a?(HandlerResult::Base)
|
|
100
|
+
|
|
101
|
+
result
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Convert key to string for keymap lookup
|
|
105
|
+
def convert_key_to_string(key)
|
|
106
|
+
return key if key.is_a?(String)
|
|
107
|
+
|
|
108
|
+
case key
|
|
109
|
+
when KeyCode::ENTER_CR, KeyCode::ENTER_LF
|
|
110
|
+
"\r"
|
|
111
|
+
when KeyCode::ESCAPE
|
|
112
|
+
"\e"
|
|
113
|
+
when KeyCode::TAB
|
|
114
|
+
"\t"
|
|
115
|
+
when KeyCode::BACKSPACE
|
|
116
|
+
"\x7f"
|
|
117
|
+
else
|
|
118
|
+
key.chr
|
|
119
|
+
end
|
|
120
|
+
rescue RangeError
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
123
|
+
|
|
37
124
|
def cursor_row
|
|
38
125
|
window.cursor_row
|
|
39
126
|
end
|