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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +18 -10
  3. data/CHANGELOG.md +162 -0
  4. data/README.md +309 -6
  5. data/docs/_config.yml +56 -0
  6. data/docs/configuration.md +314 -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 +155 -0
  13. data/lib/mui/color_manager.rb +140 -6
  14. data/lib/mui/color_scheme.rb +1 -0
  15. data/lib/mui/command_completer.rb +11 -2
  16. data/lib/mui/command_history.rb +89 -0
  17. data/lib/mui/command_line.rb +32 -2
  18. data/lib/mui/command_registry.rb +21 -2
  19. data/lib/mui/config.rb +3 -1
  20. data/lib/mui/editor.rb +90 -2
  21. data/lib/mui/floating_window.rb +53 -1
  22. data/lib/mui/handler_result.rb +13 -7
  23. data/lib/mui/highlighters/search_highlighter.rb +2 -1
  24. data/lib/mui/highlighters/syntax_highlighter.rb +4 -1
  25. data/lib/mui/insert_completion_state.rb +15 -2
  26. data/lib/mui/key_handler/base.rb +87 -0
  27. data/lib/mui/key_handler/command_mode.rb +68 -0
  28. data/lib/mui/key_handler/insert_mode.rb +10 -41
  29. data/lib/mui/key_handler/normal_mode.rb +24 -51
  30. data/lib/mui/key_handler/operators/paste_operator.rb +9 -3
  31. data/lib/mui/key_handler/search_mode.rb +10 -7
  32. data/lib/mui/key_handler/visual_mode.rb +15 -10
  33. data/lib/mui/key_notation_parser.rb +159 -0
  34. data/lib/mui/key_sequence.rb +67 -0
  35. data/lib/mui/key_sequence_buffer.rb +85 -0
  36. data/lib/mui/key_sequence_handler.rb +163 -0
  37. data/lib/mui/key_sequence_matcher.rb +79 -0
  38. data/lib/mui/line_renderer.rb +52 -1
  39. data/lib/mui/mode_manager.rb +3 -2
  40. data/lib/mui/screen.rb +30 -6
  41. data/lib/mui/search_state.rb +74 -27
  42. data/lib/mui/syntax/language_detector.rb +33 -1
  43. data/lib/mui/syntax/lexers/c_lexer.rb +2 -0
  44. data/lib/mui/syntax/lexers/css_lexer.rb +121 -0
  45. data/lib/mui/syntax/lexers/go_lexer.rb +207 -0
  46. data/lib/mui/syntax/lexers/html_lexer.rb +118 -0
  47. data/lib/mui/syntax/lexers/javascript_lexer.rb +219 -0
  48. data/lib/mui/syntax/lexers/markdown_lexer.rb +210 -0
  49. data/lib/mui/syntax/lexers/ruby_lexer.rb +3 -0
  50. data/lib/mui/syntax/lexers/rust_lexer.rb +150 -0
  51. data/lib/mui/syntax/lexers/typescript_lexer.rb +225 -0
  52. data/lib/mui/terminal_adapter/base.rb +21 -0
  53. data/lib/mui/terminal_adapter/curses.rb +37 -11
  54. data/lib/mui/themes/default.rb +263 -132
  55. data/lib/mui/version.rb +1 -1
  56. data/lib/mui/window.rb +105 -39
  57. data/lib/mui/window_manager.rb +7 -0
  58. data/lib/mui/wrap_cache.rb +40 -0
  59. data/lib/mui/wrap_helper.rb +84 -0
  60. data/lib/mui.rb +15 -0
  61. 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
@@ -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
@@ -12,14 +12,33 @@ module Mui
12
12
  end
13
13
 
14
14
  def execute(name, context, *)
15
- command = @commands[name.to_sym]
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
@@ -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
 
@@ -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
- type: :syntax_type
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
- reset
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
@@ -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