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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +13 -8
  3. data/CHANGELOG.md +99 -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/lib/mui/command_completer.rb +11 -2
  14. data/lib/mui/command_history.rb +89 -0
  15. data/lib/mui/command_line.rb +32 -2
  16. data/lib/mui/command_registry.rb +21 -2
  17. data/lib/mui/config.rb +3 -1
  18. data/lib/mui/editor.rb +78 -2
  19. data/lib/mui/handler_result.rb +13 -7
  20. data/lib/mui/highlighters/search_highlighter.rb +2 -1
  21. data/lib/mui/highlighters/syntax_highlighter.rb +3 -1
  22. data/lib/mui/key_handler/base.rb +87 -0
  23. data/lib/mui/key_handler/command_mode.rb +68 -0
  24. data/lib/mui/key_handler/insert_mode.rb +10 -41
  25. data/lib/mui/key_handler/normal_mode.rb +24 -51
  26. data/lib/mui/key_handler/operators/paste_operator.rb +9 -3
  27. data/lib/mui/key_handler/search_mode.rb +10 -7
  28. data/lib/mui/key_handler/visual_mode.rb +15 -10
  29. data/lib/mui/key_notation_parser.rb +152 -0
  30. data/lib/mui/key_sequence.rb +67 -0
  31. data/lib/mui/key_sequence_buffer.rb +85 -0
  32. data/lib/mui/key_sequence_handler.rb +163 -0
  33. data/lib/mui/key_sequence_matcher.rb +79 -0
  34. data/lib/mui/line_renderer.rb +52 -1
  35. data/lib/mui/mode_manager.rb +3 -2
  36. data/lib/mui/screen.rb +24 -6
  37. data/lib/mui/search_state.rb +61 -28
  38. data/lib/mui/syntax/language_detector.rb +33 -1
  39. data/lib/mui/syntax/lexers/css_lexer.rb +121 -0
  40. data/lib/mui/syntax/lexers/go_lexer.rb +205 -0
  41. data/lib/mui/syntax/lexers/html_lexer.rb +118 -0
  42. data/lib/mui/syntax/lexers/javascript_lexer.rb +197 -0
  43. data/lib/mui/syntax/lexers/markdown_lexer.rb +210 -0
  44. data/lib/mui/syntax/lexers/rust_lexer.rb +148 -0
  45. data/lib/mui/syntax/lexers/typescript_lexer.rb +203 -0
  46. data/lib/mui/terminal_adapter/curses.rb +13 -11
  47. data/lib/mui/version.rb +1 -1
  48. data/lib/mui/window.rb +83 -40
  49. data/lib/mui/window_manager.rb +7 -0
  50. data/lib/mui/wrap_cache.rb +40 -0
  51. data/lib/mui/wrap_helper.rb +84 -0
  52. data/lib/mui.rb +15 -0
  53. metadata +26 -3
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
@@ -315,5 +357,39 @@ module Mui
315
357
  def process_job_results
316
358
  @job_manager.poll
317
359
  end
360
+
361
+ def check_key_sequence_timeout
362
+ return unless @key_sequence_handler.pending?
363
+
364
+ mode_symbol = mode_to_symbol(@mode_manager.mode)
365
+ result = @key_sequence_handler.check_timeout(mode_symbol)
366
+
367
+ return unless result
368
+
369
+ type, data = result
370
+ case type
371
+ when KeySequenceHandler::RESULT_HANDLED
372
+ execute_keymap_handler(data)
373
+ when KeySequenceHandler::RESULT_PASSTHROUGH
374
+ # Re-process the passthrough key
375
+ handle_key(data) if data
376
+ end
377
+ end
378
+
379
+ def mode_to_symbol(mode)
380
+ case mode
381
+ when Mode::NORMAL then :normal
382
+ when Mode::INSERT then :insert
383
+ when Mode::VISUAL, Mode::VISUAL_LINE then :visual
384
+ when Mode::COMMAND then :command
385
+ when Mode::SEARCH_FORWARD, Mode::SEARCH_BACKWARD then :search
386
+ else :normal
387
+ end
388
+ end
389
+
390
+ def execute_keymap_handler(handler)
391
+ context = CommandContext.new(editor: self, buffer:, window:)
392
+ handler.call(context)
393
+ end
318
394
  end
319
395
  end
@@ -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,9 @@ module Mui
19
19
  instance_variable: :syntax_instance_variable,
20
20
  global_variable: :syntax_global_variable,
21
21
  method_call: :syntax_method_call,
22
- type: :syntax_type
22
+ type: :syntax_type,
23
+ macro: :syntax_keyword, # Rust macros (println!, vec!, etc.)
24
+ regex: :syntax_string # JavaScript/TypeScript regex literals
23
25
  }.freeze
24
26
 
25
27
  def initialize(color_scheme, buffer: nil)
@@ -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
@@ -15,6 +15,10 @@ module Mui
15
15
  end
16
16
 
17
17
  def handle(key)
18
+ # Check plugin keymaps first
19
+ plugin_result = check_plugin_keymap(key, :command)
20
+ return plugin_result if plugin_result
21
+
18
22
  case key
19
23
  when KeyCode::ESCAPE
20
24
  handle_escape
@@ -30,6 +34,10 @@ module Mui
30
34
  handle_cursor_left
31
35
  when Curses::KEY_RIGHT
32
36
  handle_cursor_right
37
+ when Curses::KEY_UP
38
+ handle_history_up
39
+ when Curses::KEY_DOWN
40
+ handle_history_down
33
41
  else
34
42
  handle_character_input(key)
35
43
  end
@@ -82,6 +90,18 @@ module Mui
82
90
  result
83
91
  end
84
92
 
93
+ def handle_history_up
94
+ @completion_state.reset
95
+ @command_line.history_previous
96
+ result
97
+ end
98
+
99
+ def handle_history_down
100
+ @completion_state.reset
101
+ @command_line.history_next
102
+ result
103
+ end
104
+
85
105
  def update_completion
86
106
  context = @command_line.completion_context
87
107
  unless context
@@ -176,6 +196,10 @@ module Mui
176
196
  handle_tab_move(command_result[:position])
177
197
  when :goto_line
178
198
  handle_goto_line(command_result[:line_number])
199
+ when :shell_command
200
+ handle_shell_command(command_result[:command])
201
+ when :shell_command_error
202
+ result(message: command_result[:message])
179
203
  when :unknown
180
204
  # Check plugin commands before reporting unknown
181
205
  plugin_result = try_plugin_command(command_result[:command])
@@ -264,7 +288,9 @@ module Mui
264
288
 
265
289
  def open_new_buffer(path)
266
290
  new_buffer = create_buffer_from_path(path)
291
+ new_buffer.undo_manager = UndoManager.new
267
292
  window.buffer = new_buffer
293
+
268
294
  result(message: "\"#{path}\" opened")
269
295
  rescue SystemCallError => e
270
296
  result(message: "Error: #{e.message}")
@@ -303,6 +329,7 @@ module Mui
303
329
  def handle_split_horizontal(path = nil)
304
330
  with_window_manager do |wm|
305
331
  buffer = path ? create_buffer_from_path(path) : nil
332
+ buffer&.undo_manager = UndoManager.new
306
333
  wm.split_horizontal(buffer)
307
334
  result
308
335
  end
@@ -311,6 +338,7 @@ module Mui
311
338
  def handle_split_vertical(path = nil)
312
339
  with_window_manager do |wm|
313
340
  buffer = path ? create_buffer_from_path(path) : nil
341
+ buffer&.undo_manager = UndoManager.new
314
342
  wm.split_vertical(buffer)
315
343
  result
316
344
  end
@@ -352,6 +380,7 @@ module Mui
352
380
  with_tab_manager do |tm|
353
381
  new_tab = tm.add
354
382
  buffer = path ? create_buffer_from_path(path) : Buffer.new
383
+ buffer.undo_manager = UndoManager.new
355
384
  new_tab.window_manager.add_window(buffer)
356
385
  result
357
386
  end
@@ -424,6 +453,45 @@ module Mui
424
453
  result
425
454
  end
426
455
 
456
+ def handle_shell_command(cmd)
457
+ return result(message: "Shell commands not available") unless @mode_manager&.editor
458
+
459
+ context = CommandContext.new(
460
+ editor: @mode_manager.editor,
461
+ buffer:,
462
+ window:
463
+ )
464
+
465
+ context.run_shell_command(cmd, on_complete: lambda { |job|
466
+ display_shell_result(job, cmd)
467
+ })
468
+
469
+ result(message: "Running: #{cmd}")
470
+ end
471
+
472
+ def display_shell_result(job, cmd)
473
+ return unless @mode_manager&.editor
474
+
475
+ output = build_shell_output(job, cmd)
476
+ @mode_manager.editor.update_or_create_scratch_buffer("[Shell Output]", output)
477
+ end
478
+
479
+ def build_shell_output(job, cmd)
480
+ res = job.result
481
+ lines = ["$ #{cmd}", ""]
482
+
483
+ lines << res[:stdout].chomp unless res[:stdout].empty?
484
+
485
+ unless res[:stderr].empty?
486
+ lines << "[stderr]"
487
+ lines << res[:stderr].chomp
488
+ end
489
+
490
+ lines << "" << "[Exit status: #{res[:exit_status]}]" unless res[:success]
491
+
492
+ lines.join("\n")
493
+ end
494
+
427
495
  def create_buffer_from_path(path)
428
496
  new_buffer = Buffer.new
429
497
  new_buffer.load(path)
@@ -8,7 +8,8 @@ module Mui
8
8
  super(mode_manager, buffer)
9
9
  @undo_manager = undo_manager
10
10
  # Start undo group unless already started (e.g., by change operator)
11
- @undo_manager&.begin_group unless group_started
11
+ # Use dynamic undo_manager to support buffer changes via :e/:sp/:vs/:tabnew
12
+ self.undo_manager&.begin_group unless group_started
12
13
  # Build word cache for fast completion (use active window's buffer)
13
14
  @word_cache = BufferWordCache.new(self.buffer)
14
15
  end
@@ -44,51 +45,13 @@ module Mui
44
45
  end
45
46
  end
46
47
 
47
- def check_plugin_keymap(key, mode_symbol)
48
- return nil unless @mode_manager&.editor
49
-
50
- key_str = convert_key_to_string(key)
51
- return nil unless key_str
52
-
53
- plugin_handler = Mui.config.keymaps[mode_symbol]&.[](key_str)
54
- return nil unless plugin_handler
55
-
56
- context = CommandContext.new(
57
- editor: @mode_manager.editor,
58
- buffer:,
59
- window:
60
- )
61
- handler_result = plugin_handler.call(context)
62
-
63
- # If handler returns nil/false, let built-in handle it
64
- return nil unless handler_result
65
-
66
- # Return a valid result to indicate the key was handled
67
- handler_result.is_a?(HandlerResult::InsertModeResult) ? handler_result : result
68
- end
69
-
70
- def convert_key_to_string(key)
71
- return key if key.is_a?(String)
72
-
73
- # Handle special Curses keys
74
- case key
75
- when KeyCode::ENTER_CR, KeyCode::ENTER_LF, Curses::KEY_ENTER
76
- "\r"
77
- else
78
- key.chr
79
- end
80
- rescue RangeError
81
- # Key code out of char range (e.g., special function keys)
82
- nil
83
- end
84
-
85
48
  private
86
49
 
87
50
  def handle_escape
88
51
  # Cancel completion if active
89
52
  editor.insert_completion_state.reset if completion_active?
90
53
 
91
- @undo_manager&.end_group
54
+ undo_manager&.end_group
92
55
  # Remove trailing whitespace from current line if it's whitespace-only (Vim behavior)
93
56
  stripped = strip_trailing_whitespace_if_empty_line
94
57
  # Move cursor back one position unless we just stripped whitespace (cursor already at 0)
@@ -107,11 +70,13 @@ module Mui
107
70
  end
108
71
 
109
72
  def handle_move_left
73
+ reset_insert_completion_state
110
74
  self.cursor_col = cursor_col - 1 if cursor_col.positive?
111
75
  result
112
76
  end
113
77
 
114
78
  def handle_move_right
79
+ reset_insert_completion_state
115
80
  self.cursor_col = cursor_col + 1 if cursor_col < current_line_length
116
81
  result
117
82
  end
@@ -134,7 +99,7 @@ module Mui
134
99
  update_completion_list if completion_active?
135
100
  elsif cursor_row.positive?
136
101
  join_with_previous_line
137
- editor.insert_completion_state.reset if completion_active?
102
+ reset_insert_completion_state
138
103
  end
139
104
  result
140
105
  end
@@ -346,6 +311,10 @@ module Mui
346
311
  end
347
312
  end
348
313
 
314
+ def reset_insert_completion_state
315
+ editor.insert_completion_state.reset if completion_active?
316
+ end
317
+
349
318
  def result(mode: nil, message: nil, quit: false)
350
319
  HandlerResult::InsertModeResult.new(mode:, message:, quit:)
351
320
  end
@@ -21,8 +21,9 @@ module Mui
21
21
  # Sync operators with current buffer/window (may have changed via tab switch)
22
22
  sync_operators
23
23
 
24
- # Check plugin keymaps first (only when no pending motion)
25
- unless @pending_motion
24
+ # Check plugin keymaps first
25
+ # Skip if: has pending motion OR (is builtin key AND no pending sequence)
26
+ unless @pending_motion || (builtin_operator_key?(key) && !pending_sequence?)
26
27
  plugin_result = check_plugin_keymap(key, :normal)
27
28
  return plugin_result if plugin_result
28
29
  end
@@ -34,49 +35,22 @@ module Mui
34
35
  end
35
36
  end
36
37
 
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)
38
+ # Check if there's a pending multi-key sequence in progress
39
+ def pending_sequence?
40
+ key_sequence_handler&.pending?
41
+ end
52
42
 
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
43
+ # Check if key starts a built-in operator (takes priority over plugins)
44
+ def builtin_operator_key?(key)
45
+ char = key_to_char(key)
46
+ return false unless char
56
47
 
57
- # Return a valid result to indicate the key was handled
58
- handler_result.is_a?(HandlerResult::NormalModeResult) ? handler_result : result
48
+ # Built-in operators and motions that set pending_motion
49
+ %w[d c y g f F t T].include?(char) || key == KeyCode::CTRL_W
59
50
  end
60
51
 
61
52
  private
62
53
 
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
54
  def initialize_operators
81
55
  @operators = {
82
56
  delete: Operators::DeleteOperator.new(
@@ -95,7 +69,7 @@ module Mui
95
69
  end
96
70
 
97
71
  def sync_operators
98
- @operators.each_value { |op| op.update(buffer:, window:) }
72
+ @operators.each_value { |op| op.update(buffer:, window:, undo_manager:) }
99
73
  end
100
74
 
101
75
  def handle_normal_key(key)
@@ -298,7 +272,7 @@ module Mui
298
272
  end
299
273
 
300
274
  def handle_open_below
301
- @undo_manager&.begin_group
275
+ undo_manager&.begin_group
302
276
  buffer.insert_line(cursor_row + 1)
303
277
  self.cursor_row = cursor_row + 1
304
278
  self.cursor_col = 0
@@ -306,7 +280,7 @@ module Mui
306
280
  end
307
281
 
308
282
  def handle_open_above
309
- @undo_manager&.begin_group
283
+ undo_manager&.begin_group
310
284
  buffer.insert_line(cursor_row)
311
285
  self.cursor_col = 0
312
286
  result(mode: Mode::INSERT, group_started: true)
@@ -410,7 +384,7 @@ module Mui
410
384
 
411
385
  # Undo/Redo handlers
412
386
  def handle_undo
413
- if @undo_manager&.undo(buffer)
387
+ if undo_manager&.undo(buffer)
414
388
  window.clamp_cursor_to_line(buffer)
415
389
  result
416
390
  else
@@ -419,7 +393,7 @@ module Mui
419
393
  end
420
394
 
421
395
  def handle_redo
422
- if @undo_manager&.redo(buffer)
396
+ if undo_manager&.redo(buffer)
423
397
  window.clamp_cursor_to_line(buffer)
424
398
  result
425
399
  else
@@ -500,9 +474,9 @@ module Mui
500
474
  return result(message: "No previous search pattern") unless @search_state&.has_pattern?
501
475
 
502
476
  match = if @search_state.direction == :forward
503
- @search_state.find_next(cursor_row, cursor_col)
477
+ @search_state.find_next(cursor_row, cursor_col, buffer:)
504
478
  else
505
- @search_state.find_previous(cursor_row, cursor_col)
479
+ @search_state.find_previous(cursor_row, cursor_col, buffer:)
506
480
  end
507
481
 
508
482
  if match
@@ -517,9 +491,9 @@ module Mui
517
491
  return result(message: "No previous search pattern") unless @search_state&.has_pattern?
518
492
 
519
493
  match = if @search_state.direction == :forward
520
- @search_state.find_previous(cursor_row, cursor_col)
494
+ @search_state.find_previous(cursor_row, cursor_col, buffer:)
521
495
  else
522
- @search_state.find_next(cursor_row, cursor_col)
496
+ @search_state.find_next(cursor_row, cursor_col, buffer:)
523
497
  end
524
498
 
525
499
  if match
@@ -538,13 +512,12 @@ module Mui
538
512
  escaped_pattern = "\\b#{Regexp.escape(word)}\\b"
539
513
 
540
514
  @search_state.set_pattern(escaped_pattern, direction)
541
- @search_state.find_all_matches(buffer)
542
515
 
543
516
  # Find next/previous match from current position
544
517
  match = if direction == :forward
545
- @search_state.find_next(cursor_row, cursor_col)
518
+ @search_state.find_next(cursor_row, cursor_col, buffer:)
546
519
  else
547
- @search_state.find_previous(cursor_row, cursor_col)
520
+ @search_state.find_previous(cursor_row, cursor_col, buffer:)
548
521
  end
549
522
 
550
523
  if match