mui 0.1.0 → 0.2.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 +158 -0
- data/CHANGELOG.md +349 -0
- data/exe/mui +1 -2
- data/lib/mui/autocmd.rb +66 -0
- data/lib/mui/buffer.rb +275 -0
- data/lib/mui/buffer_word_cache.rb +131 -0
- data/lib/mui/buffer_word_completer.rb +77 -0
- data/lib/mui/color_manager.rb +136 -0
- data/lib/mui/color_scheme.rb +63 -0
- data/lib/mui/command_completer.rb +21 -0
- data/lib/mui/command_context.rb +90 -0
- data/lib/mui/command_line.rb +137 -0
- data/lib/mui/command_registry.rb +25 -0
- data/lib/mui/completion_renderer.rb +84 -0
- data/lib/mui/completion_state.rb +58 -0
- data/lib/mui/config.rb +56 -0
- data/lib/mui/editor.rb +319 -0
- data/lib/mui/error.rb +29 -0
- data/lib/mui/file_completer.rb +51 -0
- data/lib/mui/floating_window.rb +161 -0
- data/lib/mui/handler_result.rb +101 -0
- data/lib/mui/highlight.rb +22 -0
- data/lib/mui/highlighters/base.rb +23 -0
- data/lib/mui/highlighters/search_highlighter.rb +26 -0
- data/lib/mui/highlighters/selection_highlighter.rb +48 -0
- data/lib/mui/highlighters/syntax_highlighter.rb +105 -0
- data/lib/mui/input.rb +17 -0
- data/lib/mui/insert_completion_renderer.rb +92 -0
- data/lib/mui/insert_completion_state.rb +77 -0
- data/lib/mui/job.rb +81 -0
- data/lib/mui/job_manager.rb +113 -0
- data/lib/mui/key_code.rb +30 -0
- data/lib/mui/key_handler/base.rb +100 -0
- data/lib/mui/key_handler/command_mode.rb +443 -0
- data/lib/mui/key_handler/insert_mode.rb +354 -0
- data/lib/mui/key_handler/motions/motion_handler.rb +56 -0
- data/lib/mui/key_handler/normal_mode.rb +579 -0
- data/lib/mui/key_handler/operators/base_operator.rb +134 -0
- data/lib/mui/key_handler/operators/change_operator.rb +179 -0
- data/lib/mui/key_handler/operators/delete_operator.rb +176 -0
- data/lib/mui/key_handler/operators/paste_operator.rb +113 -0
- data/lib/mui/key_handler/operators/yank_operator.rb +127 -0
- data/lib/mui/key_handler/search_mode.rb +188 -0
- data/lib/mui/key_handler/visual_line_mode.rb +20 -0
- data/lib/mui/key_handler/visual_mode.rb +397 -0
- data/lib/mui/key_handler/window_command.rb +112 -0
- data/lib/mui/key_handler.rb +16 -0
- data/lib/mui/layout/calculator.rb +15 -0
- data/lib/mui/layout/leaf_node.rb +33 -0
- data/lib/mui/layout/node.rb +29 -0
- data/lib/mui/layout/split_node.rb +132 -0
- data/lib/mui/line_renderer.rb +122 -0
- data/lib/mui/mode.rb +13 -0
- data/lib/mui/mode_manager.rb +185 -0
- data/lib/mui/motion.rb +139 -0
- data/lib/mui/plugin.rb +35 -0
- data/lib/mui/plugin_manager.rb +106 -0
- data/lib/mui/register.rb +110 -0
- data/lib/mui/screen.rb +85 -0
- data/lib/mui/search_completer.rb +50 -0
- data/lib/mui/search_input.rb +40 -0
- data/lib/mui/search_state.rb +88 -0
- data/lib/mui/selection.rb +55 -0
- data/lib/mui/status_line_renderer.rb +40 -0
- data/lib/mui/syntax/language_detector.rb +74 -0
- data/lib/mui/syntax/lexer_base.rb +106 -0
- data/lib/mui/syntax/lexers/c_lexer.rb +127 -0
- data/lib/mui/syntax/lexers/ruby_lexer.rb +114 -0
- data/lib/mui/syntax/token.rb +42 -0
- data/lib/mui/syntax/token_cache.rb +91 -0
- data/lib/mui/tab_bar_renderer.rb +87 -0
- data/lib/mui/tab_manager.rb +96 -0
- data/lib/mui/tab_page.rb +35 -0
- data/lib/mui/terminal_adapter/base.rb +92 -0
- data/lib/mui/terminal_adapter/curses.rb +162 -0
- data/lib/mui/terminal_adapter.rb +4 -0
- data/lib/mui/themes/default.rb +315 -0
- data/lib/mui/undo_manager.rb +83 -0
- data/lib/mui/undoable_action.rb +175 -0
- data/lib/mui/unicode_width.rb +100 -0
- data/lib/mui/version.rb +1 -1
- data/lib/mui/window.rb +158 -0
- data/lib/mui/window_manager.rb +249 -0
- data/lib/mui.rb +156 -2
- metadata +98 -3
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mui
|
|
4
|
+
module KeyHandler
|
|
5
|
+
# Handles key inputs in Normal mode
|
|
6
|
+
class NormalMode < Base
|
|
7
|
+
include Motions::MotionHandler
|
|
8
|
+
|
|
9
|
+
def initialize(mode_manager, buffer, register = nil, undo_manager: nil, search_state: nil)
|
|
10
|
+
super(mode_manager, buffer)
|
|
11
|
+
@register = register || Register.new
|
|
12
|
+
@undo_manager = undo_manager
|
|
13
|
+
@search_state = search_state
|
|
14
|
+
@pending_motion = nil
|
|
15
|
+
@pending_register = nil
|
|
16
|
+
@window_command = nil
|
|
17
|
+
initialize_operators
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def handle(key)
|
|
21
|
+
# Sync operators with current buffer/window (may have changed via tab switch)
|
|
22
|
+
sync_operators
|
|
23
|
+
|
|
24
|
+
# Check plugin keymaps first (only when no pending motion)
|
|
25
|
+
unless @pending_motion
|
|
26
|
+
plugin_result = check_plugin_keymap(key, :normal)
|
|
27
|
+
return plugin_result if plugin_result
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
if @pending_motion
|
|
31
|
+
handle_pending_motion(key)
|
|
32
|
+
else
|
|
33
|
+
handle_normal_key(key)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def check_plugin_keymap(key, mode_symbol)
|
|
38
|
+
return nil unless @mode_manager&.editor
|
|
39
|
+
|
|
40
|
+
key_str = convert_key_to_string(key)
|
|
41
|
+
return nil unless key_str
|
|
42
|
+
|
|
43
|
+
plugin_handler = Mui.config.keymaps[mode_symbol]&.[](key_str)
|
|
44
|
+
return nil unless plugin_handler
|
|
45
|
+
|
|
46
|
+
context = CommandContext.new(
|
|
47
|
+
editor: @mode_manager.editor,
|
|
48
|
+
buffer:,
|
|
49
|
+
window:
|
|
50
|
+
)
|
|
51
|
+
handler_result = plugin_handler.call(context)
|
|
52
|
+
|
|
53
|
+
# If handler returns nil/false, let built-in handle it
|
|
54
|
+
# This allows buffer-specific keymaps to pass through for other buffers
|
|
55
|
+
return nil unless handler_result
|
|
56
|
+
|
|
57
|
+
# Return a valid result to indicate the key was handled
|
|
58
|
+
handler_result.is_a?(HandlerResult::NormalModeResult) ? handler_result : result
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
# Convert key to string for keymap lookup
|
|
64
|
+
# Handles special keys like Enter that have Curses constants
|
|
65
|
+
def convert_key_to_string(key)
|
|
66
|
+
return key if key.is_a?(String)
|
|
67
|
+
|
|
68
|
+
# Handle special Curses keys
|
|
69
|
+
case key
|
|
70
|
+
when KeyCode::ENTER_CR, KeyCode::ENTER_LF, Curses::KEY_ENTER
|
|
71
|
+
"\r"
|
|
72
|
+
else
|
|
73
|
+
key.chr
|
|
74
|
+
end
|
|
75
|
+
rescue RangeError
|
|
76
|
+
# Key code out of char range (e.g., special function keys)
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def initialize_operators
|
|
81
|
+
@operators = {
|
|
82
|
+
delete: Operators::DeleteOperator.new(
|
|
83
|
+
buffer:, window:, register: @register, undo_manager: @undo_manager
|
|
84
|
+
),
|
|
85
|
+
change: Operators::ChangeOperator.new(
|
|
86
|
+
buffer:, window:, register: @register, undo_manager: @undo_manager
|
|
87
|
+
),
|
|
88
|
+
yank: Operators::YankOperator.new(
|
|
89
|
+
buffer:, window:, register: @register
|
|
90
|
+
),
|
|
91
|
+
paste: Operators::PasteOperator.new(
|
|
92
|
+
buffer:, window:, register: @register
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def sync_operators
|
|
98
|
+
@operators.each_value { |op| op.update(buffer:, window:) }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def handle_normal_key(key)
|
|
102
|
+
case key
|
|
103
|
+
when "h", Curses::KEY_LEFT
|
|
104
|
+
handle_move_left
|
|
105
|
+
when "j", Curses::KEY_DOWN
|
|
106
|
+
handle_move_down
|
|
107
|
+
when "k", Curses::KEY_UP
|
|
108
|
+
handle_move_up
|
|
109
|
+
when "l", Curses::KEY_RIGHT
|
|
110
|
+
handle_move_right
|
|
111
|
+
when "w"
|
|
112
|
+
handle_word_forward
|
|
113
|
+
when "b"
|
|
114
|
+
handle_word_backward
|
|
115
|
+
when "e"
|
|
116
|
+
handle_word_end
|
|
117
|
+
when "0"
|
|
118
|
+
handle_line_start
|
|
119
|
+
when "^"
|
|
120
|
+
handle_first_non_blank
|
|
121
|
+
when "$"
|
|
122
|
+
handle_line_end
|
|
123
|
+
when "g"
|
|
124
|
+
@pending_motion = :g
|
|
125
|
+
result
|
|
126
|
+
when "G"
|
|
127
|
+
handle_file_end
|
|
128
|
+
when "f"
|
|
129
|
+
@pending_motion = :f
|
|
130
|
+
result
|
|
131
|
+
when "F"
|
|
132
|
+
@pending_motion = :F
|
|
133
|
+
result
|
|
134
|
+
when "t"
|
|
135
|
+
@pending_motion = :t
|
|
136
|
+
result
|
|
137
|
+
when "T"
|
|
138
|
+
@pending_motion = :T
|
|
139
|
+
result
|
|
140
|
+
when "i"
|
|
141
|
+
return readonly_error if buffer.readonly?
|
|
142
|
+
|
|
143
|
+
result(mode: Mode::INSERT)
|
|
144
|
+
when "a"
|
|
145
|
+
return readonly_error if buffer.readonly?
|
|
146
|
+
|
|
147
|
+
handle_append
|
|
148
|
+
when "o"
|
|
149
|
+
return readonly_error if buffer.readonly?
|
|
150
|
+
|
|
151
|
+
handle_open_below
|
|
152
|
+
when "O"
|
|
153
|
+
return readonly_error if buffer.readonly?
|
|
154
|
+
|
|
155
|
+
handle_open_above
|
|
156
|
+
when "x"
|
|
157
|
+
return readonly_error if buffer.readonly?
|
|
158
|
+
|
|
159
|
+
handle_delete_char
|
|
160
|
+
when "d"
|
|
161
|
+
return readonly_error if buffer.readonly?
|
|
162
|
+
|
|
163
|
+
@pending_motion = :d
|
|
164
|
+
result
|
|
165
|
+
when "c"
|
|
166
|
+
return readonly_error if buffer.readonly?
|
|
167
|
+
|
|
168
|
+
@pending_motion = :c
|
|
169
|
+
result
|
|
170
|
+
when "y"
|
|
171
|
+
@pending_motion = :y
|
|
172
|
+
result
|
|
173
|
+
when "p"
|
|
174
|
+
return readonly_error if buffer.readonly?
|
|
175
|
+
|
|
176
|
+
handle_paste_after
|
|
177
|
+
when "P"
|
|
178
|
+
return readonly_error if buffer.readonly?
|
|
179
|
+
|
|
180
|
+
handle_paste_before
|
|
181
|
+
when ":"
|
|
182
|
+
result(mode: Mode::COMMAND)
|
|
183
|
+
when "v"
|
|
184
|
+
result(mode: Mode::VISUAL, start_selection: true)
|
|
185
|
+
when "V"
|
|
186
|
+
result(mode: Mode::VISUAL_LINE, start_selection: true, line_mode: true)
|
|
187
|
+
when '"'
|
|
188
|
+
@pending_motion = :register_select
|
|
189
|
+
result
|
|
190
|
+
when "u"
|
|
191
|
+
handle_undo
|
|
192
|
+
when 18 # Ctrl-r
|
|
193
|
+
handle_redo
|
|
194
|
+
when "/"
|
|
195
|
+
result(mode: Mode::SEARCH_FORWARD)
|
|
196
|
+
when "?"
|
|
197
|
+
result(mode: Mode::SEARCH_BACKWARD)
|
|
198
|
+
when "n"
|
|
199
|
+
handle_search_next
|
|
200
|
+
when "N"
|
|
201
|
+
handle_search_previous
|
|
202
|
+
when "*"
|
|
203
|
+
handle_search_word(:forward)
|
|
204
|
+
when "#"
|
|
205
|
+
handle_search_word(:backward)
|
|
206
|
+
when KeyCode::CTRL_W
|
|
207
|
+
@pending_motion = :window_command
|
|
208
|
+
result
|
|
209
|
+
else
|
|
210
|
+
result
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def handle_pending_motion(key)
|
|
215
|
+
# Window command doesn't need char conversion
|
|
216
|
+
return dispatch_window_command(key) if @pending_motion == :window_command
|
|
217
|
+
|
|
218
|
+
char = key_to_char(key)
|
|
219
|
+
return clear_pending unless char
|
|
220
|
+
|
|
221
|
+
case @pending_motion
|
|
222
|
+
when :register_select
|
|
223
|
+
handle_register_select(char)
|
|
224
|
+
when :g
|
|
225
|
+
dispatch_g_command(char)
|
|
226
|
+
when :d
|
|
227
|
+
dispatch_delete_operator(char)
|
|
228
|
+
when :dg
|
|
229
|
+
dispatch_delete_to_file_start(char)
|
|
230
|
+
when :df, :dF, :dt, :dT
|
|
231
|
+
dispatch_delete_find_char(char)
|
|
232
|
+
when :c
|
|
233
|
+
dispatch_change_operator(char)
|
|
234
|
+
when :cg
|
|
235
|
+
dispatch_change_to_file_start(char)
|
|
236
|
+
when :cf, :cF, :ct, :cT
|
|
237
|
+
dispatch_change_find_char(char)
|
|
238
|
+
when :y
|
|
239
|
+
dispatch_yank_operator(char)
|
|
240
|
+
when :yg
|
|
241
|
+
dispatch_yank_to_file_start(char)
|
|
242
|
+
when :yf, :yF, :yt, :yT
|
|
243
|
+
dispatch_yank_find_char(char)
|
|
244
|
+
else
|
|
245
|
+
motion_result = execute_pending_motion(char)
|
|
246
|
+
apply_motion(motion_result) if motion_result
|
|
247
|
+
clear_pending
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def clear_pending
|
|
252
|
+
@pending_motion = nil
|
|
253
|
+
@pending_register = nil
|
|
254
|
+
result
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def handle_register_select(char)
|
|
258
|
+
if valid_register_name?(char)
|
|
259
|
+
@pending_register = char
|
|
260
|
+
@pending_motion = nil
|
|
261
|
+
result
|
|
262
|
+
else
|
|
263
|
+
clear_pending
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def valid_register_name?(char)
|
|
268
|
+
Register::NAMED_REGISTERS.include?(char) ||
|
|
269
|
+
Register::DELETE_HISTORY_REGISTERS.include?(char) ||
|
|
270
|
+
[Register::YANK_REGISTER, Register::BLACK_HOLE_REGISTER, Register::UNNAMED_REGISTER].include?(char)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Movement handlers
|
|
274
|
+
def handle_move_left
|
|
275
|
+
window.move_left
|
|
276
|
+
result
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def handle_move_down
|
|
280
|
+
window.move_down
|
|
281
|
+
result
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def handle_move_up
|
|
285
|
+
window.move_up
|
|
286
|
+
result
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def handle_move_right
|
|
290
|
+
window.move_right
|
|
291
|
+
result
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Edit handlers
|
|
295
|
+
def handle_append
|
|
296
|
+
self.cursor_col = cursor_col + 1 if current_line_length.positive?
|
|
297
|
+
result(mode: Mode::INSERT)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def handle_open_below
|
|
301
|
+
@undo_manager&.begin_group
|
|
302
|
+
buffer.insert_line(cursor_row + 1)
|
|
303
|
+
self.cursor_row = cursor_row + 1
|
|
304
|
+
self.cursor_col = 0
|
|
305
|
+
result(mode: Mode::INSERT, group_started: true)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def handle_open_above
|
|
309
|
+
@undo_manager&.begin_group
|
|
310
|
+
buffer.insert_line(cursor_row)
|
|
311
|
+
self.cursor_col = 0
|
|
312
|
+
result(mode: Mode::INSERT, group_started: true)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def handle_delete_char
|
|
316
|
+
buffer.delete_char(cursor_row, cursor_col)
|
|
317
|
+
result
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Delete operator dispatchers
|
|
321
|
+
def dispatch_delete_operator(char)
|
|
322
|
+
status = @operators[:delete].handle_pending(char, pending_register: @pending_register)
|
|
323
|
+
handle_operator_result(status)
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def dispatch_delete_to_file_start(char)
|
|
327
|
+
status = @operators[:delete].handle_to_file_start(char)
|
|
328
|
+
handle_operator_result(status)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def dispatch_delete_find_char(char)
|
|
332
|
+
status = @operators[:delete].handle_find_char(char, @pending_motion)
|
|
333
|
+
handle_operator_result(status)
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Change operator dispatchers
|
|
337
|
+
def dispatch_change_operator(char)
|
|
338
|
+
status = @operators[:change].handle_pending(char, pending_register: @pending_register)
|
|
339
|
+
handle_operator_result(status)
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def dispatch_change_to_file_start(char)
|
|
343
|
+
status = @operators[:change].handle_to_file_start(char)
|
|
344
|
+
handle_operator_result(status)
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def dispatch_change_find_char(char)
|
|
348
|
+
status = @operators[:change].handle_find_char(char, @pending_motion)
|
|
349
|
+
handle_operator_result(status)
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# Yank operator dispatchers
|
|
353
|
+
def dispatch_yank_operator(char)
|
|
354
|
+
status = @operators[:yank].handle_pending(char, pending_register: @pending_register)
|
|
355
|
+
handle_operator_result(status)
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def dispatch_yank_to_file_start(char)
|
|
359
|
+
status = @operators[:yank].handle_to_file_start(char)
|
|
360
|
+
handle_operator_result(status)
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def dispatch_yank_find_char(char)
|
|
364
|
+
status = @operators[:yank].handle_find_char(char, @pending_motion)
|
|
365
|
+
handle_operator_result(status)
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Paste handlers (delegate to operator)
|
|
369
|
+
def handle_paste_after
|
|
370
|
+
@operators[:paste].paste_after(pending_register: @pending_register)
|
|
371
|
+
@pending_register = nil
|
|
372
|
+
result
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def handle_paste_before
|
|
376
|
+
@operators[:paste].paste_before(pending_register: @pending_register)
|
|
377
|
+
@pending_register = nil
|
|
378
|
+
result
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def handle_operator_result(status)
|
|
382
|
+
case status
|
|
383
|
+
when :insert_mode
|
|
384
|
+
@pending_motion = nil
|
|
385
|
+
@pending_register = nil
|
|
386
|
+
result(mode: Mode::INSERT)
|
|
387
|
+
when /^pending_/
|
|
388
|
+
@pending_motion = status.to_s.sub("pending_", "").to_sym
|
|
389
|
+
result
|
|
390
|
+
else
|
|
391
|
+
# :done, :cancel, or any other status
|
|
392
|
+
clear_pending
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def result(mode: nil, message: nil, quit: false, start_selection: false, line_mode: false, group_started: false)
|
|
397
|
+
HandlerResult::NormalModeResult.new(
|
|
398
|
+
mode:,
|
|
399
|
+
message:,
|
|
400
|
+
quit:,
|
|
401
|
+
start_selection:,
|
|
402
|
+
line_mode:,
|
|
403
|
+
group_started:
|
|
404
|
+
)
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def readonly_error
|
|
408
|
+
result(message: "E21: Cannot make changes, buffer is readonly")
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# Undo/Redo handlers
|
|
412
|
+
def handle_undo
|
|
413
|
+
if @undo_manager&.undo(buffer)
|
|
414
|
+
window.clamp_cursor_to_line(buffer)
|
|
415
|
+
result
|
|
416
|
+
else
|
|
417
|
+
result(message: "Already at oldest change")
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def handle_redo
|
|
422
|
+
if @undo_manager&.redo(buffer)
|
|
423
|
+
window.clamp_cursor_to_line(buffer)
|
|
424
|
+
result
|
|
425
|
+
else
|
|
426
|
+
result(message: "Already at newest change")
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# Window command dispatcher
|
|
431
|
+
def dispatch_window_command(key)
|
|
432
|
+
window_manager = @mode_manager&.window_manager
|
|
433
|
+
unless window_manager
|
|
434
|
+
@pending_motion = nil
|
|
435
|
+
return result(message: "Window commands not available")
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
@window_command ||= WindowCommand.new(window_manager)
|
|
439
|
+
@window_command.handle(key)
|
|
440
|
+
clear_pending
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# g command dispatcher (gg, gt, gT)
|
|
444
|
+
def dispatch_g_command(char)
|
|
445
|
+
case char
|
|
446
|
+
when "g"
|
|
447
|
+
# gg - go to file start
|
|
448
|
+
motion_result = Motion.file_start(buffer, cursor_row, cursor_col)
|
|
449
|
+
apply_motion(motion_result) if motion_result
|
|
450
|
+
clear_pending
|
|
451
|
+
when "t"
|
|
452
|
+
# gt - next tab
|
|
453
|
+
handle_tab_next
|
|
454
|
+
when "T"
|
|
455
|
+
# gT - previous tab
|
|
456
|
+
handle_tab_prev
|
|
457
|
+
when "v"
|
|
458
|
+
# gv - restore last visual selection
|
|
459
|
+
handle_restore_visual
|
|
460
|
+
else
|
|
461
|
+
clear_pending
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
def handle_restore_visual
|
|
466
|
+
@pending_motion = nil
|
|
467
|
+
if @mode_manager.last_visual_selection
|
|
468
|
+
@mode_manager.restore_visual_selection
|
|
469
|
+
line_mode = @mode_manager.last_visual_selection[:line_mode]
|
|
470
|
+
result(mode: line_mode ? Mode::VISUAL_LINE : Mode::VISUAL)
|
|
471
|
+
else
|
|
472
|
+
result(message: "No previous visual selection")
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
def handle_tab_next
|
|
477
|
+
tab_manager = @mode_manager&.editor&.tab_manager
|
|
478
|
+
unless tab_manager
|
|
479
|
+
@pending_motion = nil
|
|
480
|
+
return result(message: "Tab commands not available")
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
tab_manager.next_tab
|
|
484
|
+
clear_pending
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
def handle_tab_prev
|
|
488
|
+
tab_manager = @mode_manager&.editor&.tab_manager
|
|
489
|
+
unless tab_manager
|
|
490
|
+
@pending_motion = nil
|
|
491
|
+
return result(message: "Tab commands not available")
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
tab_manager.prev_tab
|
|
495
|
+
clear_pending
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
# Search handlers
|
|
499
|
+
def handle_search_next
|
|
500
|
+
return result(message: "No previous search pattern") unless @search_state&.has_pattern?
|
|
501
|
+
|
|
502
|
+
match = if @search_state.direction == :forward
|
|
503
|
+
@search_state.find_next(cursor_row, cursor_col)
|
|
504
|
+
else
|
|
505
|
+
@search_state.find_previous(cursor_row, cursor_col)
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
if match
|
|
509
|
+
apply_motion(match)
|
|
510
|
+
result
|
|
511
|
+
else
|
|
512
|
+
result(message: "Pattern not found: #{@search_state.pattern}")
|
|
513
|
+
end
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
def handle_search_previous
|
|
517
|
+
return result(message: "No previous search pattern") unless @search_state&.has_pattern?
|
|
518
|
+
|
|
519
|
+
match = if @search_state.direction == :forward
|
|
520
|
+
@search_state.find_previous(cursor_row, cursor_col)
|
|
521
|
+
else
|
|
522
|
+
@search_state.find_next(cursor_row, cursor_col)
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
if match
|
|
526
|
+
apply_motion(match)
|
|
527
|
+
result
|
|
528
|
+
else
|
|
529
|
+
result(message: "Pattern not found: #{@search_state.pattern}")
|
|
530
|
+
end
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
def handle_search_word(direction)
|
|
534
|
+
word = word_under_cursor
|
|
535
|
+
return result if word.nil? || word.empty?
|
|
536
|
+
|
|
537
|
+
# Use word boundary for whole word matching (Vim behavior)
|
|
538
|
+
escaped_pattern = "\\b#{Regexp.escape(word)}\\b"
|
|
539
|
+
|
|
540
|
+
@search_state.set_pattern(escaped_pattern, direction)
|
|
541
|
+
@search_state.find_all_matches(buffer)
|
|
542
|
+
|
|
543
|
+
# Find next/previous match from current position
|
|
544
|
+
match = if direction == :forward
|
|
545
|
+
@search_state.find_next(cursor_row, cursor_col)
|
|
546
|
+
else
|
|
547
|
+
@search_state.find_previous(cursor_row, cursor_col)
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
if match
|
|
551
|
+
apply_motion(match)
|
|
552
|
+
result
|
|
553
|
+
else
|
|
554
|
+
result(message: "Pattern not found: #{word}")
|
|
555
|
+
end
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
def word_under_cursor
|
|
559
|
+
line = buffer.line(cursor_row)
|
|
560
|
+
return nil if line.nil? || line.empty?
|
|
561
|
+
|
|
562
|
+
col = cursor_col
|
|
563
|
+
return nil if col >= line.length
|
|
564
|
+
|
|
565
|
+
# Check if cursor is on a word character
|
|
566
|
+
return nil unless line[col]&.match?(/\w/)
|
|
567
|
+
|
|
568
|
+
# Find word boundaries
|
|
569
|
+
start_col = col
|
|
570
|
+
start_col -= 1 while start_col.positive? && line[start_col - 1]&.match?(/\w/)
|
|
571
|
+
|
|
572
|
+
end_col = col
|
|
573
|
+
end_col += 1 while end_col < line.length && line[end_col]&.match?(/\w/)
|
|
574
|
+
|
|
575
|
+
line[start_col...end_col]
|
|
576
|
+
end
|
|
577
|
+
end
|
|
578
|
+
end
|
|
579
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mui
|
|
4
|
+
module KeyHandler
|
|
5
|
+
module Operators
|
|
6
|
+
# Base class for operator implementations (delete, change, yank, paste)
|
|
7
|
+
# Provides common functionality for text extraction, deletion, and cursor management
|
|
8
|
+
class BaseOperator
|
|
9
|
+
def initialize(buffer:, window:, register:, undo_manager: nil)
|
|
10
|
+
@buffer = buffer
|
|
11
|
+
@window = window
|
|
12
|
+
@register = register
|
|
13
|
+
@undo_manager = undo_manager
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Update dependencies (called when window changes)
|
|
17
|
+
def update(buffer: nil, window: nil, register: nil, undo_manager: nil)
|
|
18
|
+
@buffer = buffer if buffer
|
|
19
|
+
@window = window if window
|
|
20
|
+
@register = register if register
|
|
21
|
+
@undo_manager = undo_manager if undo_manager
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Handle pending operator motion
|
|
25
|
+
def handle_pending(_char, pending_register: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
26
|
+
raise Mui::MethodNotOverriddenError, :handle_pending
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
protected
|
|
30
|
+
|
|
31
|
+
attr_reader :buffer, :window, :register, :undo_manager
|
|
32
|
+
|
|
33
|
+
# Cursor accessors
|
|
34
|
+
def cursor_row
|
|
35
|
+
@window.cursor_row
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def cursor_col
|
|
39
|
+
@window.cursor_col
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def cursor_row=(value)
|
|
43
|
+
@window.cursor_row = value
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def cursor_col=(value)
|
|
47
|
+
@window.cursor_col = value
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Text extraction methods
|
|
51
|
+
def extract_text(start_pos, end_pos, inclusive: false)
|
|
52
|
+
if start_pos[:row] == end_pos[:row]
|
|
53
|
+
extract_text_same_line(start_pos, end_pos, inclusive:)
|
|
54
|
+
else
|
|
55
|
+
extract_text_across_lines(start_pos, end_pos, inclusive:)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def extract_text_same_line(start_pos, end_pos, inclusive: false)
|
|
60
|
+
from_col = [start_pos[:col], end_pos[:col]].min
|
|
61
|
+
to_col = [start_pos[:col], end_pos[:col]].max
|
|
62
|
+
to_col -= 1 unless inclusive
|
|
63
|
+
return "" if to_col < from_col
|
|
64
|
+
|
|
65
|
+
@buffer.line(start_pos[:row])[from_col..to_col] || ""
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def extract_text_across_lines(start_pos, end_pos, inclusive: false)
|
|
69
|
+
from_row, to_row = [start_pos[:row], end_pos[:row]].minmax
|
|
70
|
+
from_col = from_row == start_pos[:row] ? start_pos[:col] : end_pos[:col]
|
|
71
|
+
to_col = to_row == end_pos[:row] ? end_pos[:col] : start_pos[:col]
|
|
72
|
+
to_col -= 1 unless inclusive
|
|
73
|
+
|
|
74
|
+
lines = []
|
|
75
|
+
(from_row..to_row).each do |row|
|
|
76
|
+
line = @buffer.line(row)
|
|
77
|
+
lines << if row == from_row
|
|
78
|
+
line[from_col..]
|
|
79
|
+
elsif row == to_row
|
|
80
|
+
line[0..to_col]
|
|
81
|
+
else
|
|
82
|
+
line
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
lines.join("\n")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Motion calculation
|
|
89
|
+
def calculate_motion_end(motion_type)
|
|
90
|
+
case motion_type
|
|
91
|
+
when :word_forward
|
|
92
|
+
Motion.word_forward(@buffer, cursor_row, cursor_col)
|
|
93
|
+
when :word_end
|
|
94
|
+
Motion.word_end(@buffer, cursor_row, cursor_col)
|
|
95
|
+
when :word_backward
|
|
96
|
+
Motion.word_backward(@buffer, cursor_row, cursor_col)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Delete execution methods (used by delete and change operators)
|
|
101
|
+
def execute_delete(start_pos, end_pos, inclusive: false, clamp: true)
|
|
102
|
+
if start_pos[:row] == end_pos[:row]
|
|
103
|
+
execute_delete_same_line(start_pos, end_pos, inclusive:, clamp:)
|
|
104
|
+
else
|
|
105
|
+
execute_delete_across_lines(start_pos, end_pos, inclusive:, clamp:)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def execute_delete_same_line(start_pos, end_pos, inclusive: false, clamp: true)
|
|
110
|
+
from_col = [start_pos[:col], end_pos[:col]].min
|
|
111
|
+
to_col = [start_pos[:col], end_pos[:col]].max
|
|
112
|
+
to_col -= 1 unless inclusive
|
|
113
|
+
return if to_col < from_col
|
|
114
|
+
|
|
115
|
+
@buffer.delete_range(start_pos[:row], from_col, start_pos[:row], to_col)
|
|
116
|
+
self.cursor_col = from_col
|
|
117
|
+
@window.clamp_cursor_to_line(@buffer) if clamp
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def execute_delete_across_lines(start_pos, end_pos, inclusive: false, clamp: true)
|
|
121
|
+
from_row, to_row = [start_pos[:row], end_pos[:row]].minmax
|
|
122
|
+
from_col = from_row == start_pos[:row] ? start_pos[:col] : end_pos[:col]
|
|
123
|
+
to_col = to_row == end_pos[:row] ? end_pos[:col] : start_pos[:col]
|
|
124
|
+
to_col -= 1 unless inclusive
|
|
125
|
+
|
|
126
|
+
@buffer.delete_range(from_row, from_col, to_row, to_col)
|
|
127
|
+
self.cursor_row = from_row
|
|
128
|
+
self.cursor_col = from_col
|
|
129
|
+
@window.clamp_cursor_to_line(@buffer) if clamp
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|