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.
Files changed (86) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +158 -0
  3. data/CHANGELOG.md +349 -0
  4. data/exe/mui +1 -2
  5. data/lib/mui/autocmd.rb +66 -0
  6. data/lib/mui/buffer.rb +275 -0
  7. data/lib/mui/buffer_word_cache.rb +131 -0
  8. data/lib/mui/buffer_word_completer.rb +77 -0
  9. data/lib/mui/color_manager.rb +136 -0
  10. data/lib/mui/color_scheme.rb +63 -0
  11. data/lib/mui/command_completer.rb +21 -0
  12. data/lib/mui/command_context.rb +90 -0
  13. data/lib/mui/command_line.rb +137 -0
  14. data/lib/mui/command_registry.rb +25 -0
  15. data/lib/mui/completion_renderer.rb +84 -0
  16. data/lib/mui/completion_state.rb +58 -0
  17. data/lib/mui/config.rb +56 -0
  18. data/lib/mui/editor.rb +319 -0
  19. data/lib/mui/error.rb +29 -0
  20. data/lib/mui/file_completer.rb +51 -0
  21. data/lib/mui/floating_window.rb +161 -0
  22. data/lib/mui/handler_result.rb +101 -0
  23. data/lib/mui/highlight.rb +22 -0
  24. data/lib/mui/highlighters/base.rb +23 -0
  25. data/lib/mui/highlighters/search_highlighter.rb +26 -0
  26. data/lib/mui/highlighters/selection_highlighter.rb +48 -0
  27. data/lib/mui/highlighters/syntax_highlighter.rb +105 -0
  28. data/lib/mui/input.rb +17 -0
  29. data/lib/mui/insert_completion_renderer.rb +92 -0
  30. data/lib/mui/insert_completion_state.rb +77 -0
  31. data/lib/mui/job.rb +81 -0
  32. data/lib/mui/job_manager.rb +113 -0
  33. data/lib/mui/key_code.rb +30 -0
  34. data/lib/mui/key_handler/base.rb +100 -0
  35. data/lib/mui/key_handler/command_mode.rb +443 -0
  36. data/lib/mui/key_handler/insert_mode.rb +354 -0
  37. data/lib/mui/key_handler/motions/motion_handler.rb +56 -0
  38. data/lib/mui/key_handler/normal_mode.rb +579 -0
  39. data/lib/mui/key_handler/operators/base_operator.rb +134 -0
  40. data/lib/mui/key_handler/operators/change_operator.rb +179 -0
  41. data/lib/mui/key_handler/operators/delete_operator.rb +176 -0
  42. data/lib/mui/key_handler/operators/paste_operator.rb +113 -0
  43. data/lib/mui/key_handler/operators/yank_operator.rb +127 -0
  44. data/lib/mui/key_handler/search_mode.rb +188 -0
  45. data/lib/mui/key_handler/visual_line_mode.rb +20 -0
  46. data/lib/mui/key_handler/visual_mode.rb +397 -0
  47. data/lib/mui/key_handler/window_command.rb +112 -0
  48. data/lib/mui/key_handler.rb +16 -0
  49. data/lib/mui/layout/calculator.rb +15 -0
  50. data/lib/mui/layout/leaf_node.rb +33 -0
  51. data/lib/mui/layout/node.rb +29 -0
  52. data/lib/mui/layout/split_node.rb +132 -0
  53. data/lib/mui/line_renderer.rb +122 -0
  54. data/lib/mui/mode.rb +13 -0
  55. data/lib/mui/mode_manager.rb +185 -0
  56. data/lib/mui/motion.rb +139 -0
  57. data/lib/mui/plugin.rb +35 -0
  58. data/lib/mui/plugin_manager.rb +106 -0
  59. data/lib/mui/register.rb +110 -0
  60. data/lib/mui/screen.rb +85 -0
  61. data/lib/mui/search_completer.rb +50 -0
  62. data/lib/mui/search_input.rb +40 -0
  63. data/lib/mui/search_state.rb +88 -0
  64. data/lib/mui/selection.rb +55 -0
  65. data/lib/mui/status_line_renderer.rb +40 -0
  66. data/lib/mui/syntax/language_detector.rb +74 -0
  67. data/lib/mui/syntax/lexer_base.rb +106 -0
  68. data/lib/mui/syntax/lexers/c_lexer.rb +127 -0
  69. data/lib/mui/syntax/lexers/ruby_lexer.rb +114 -0
  70. data/lib/mui/syntax/token.rb +42 -0
  71. data/lib/mui/syntax/token_cache.rb +91 -0
  72. data/lib/mui/tab_bar_renderer.rb +87 -0
  73. data/lib/mui/tab_manager.rb +96 -0
  74. data/lib/mui/tab_page.rb +35 -0
  75. data/lib/mui/terminal_adapter/base.rb +92 -0
  76. data/lib/mui/terminal_adapter/curses.rb +162 -0
  77. data/lib/mui/terminal_adapter.rb +4 -0
  78. data/lib/mui/themes/default.rb +315 -0
  79. data/lib/mui/undo_manager.rb +83 -0
  80. data/lib/mui/undoable_action.rb +175 -0
  81. data/lib/mui/unicode_width.rb +100 -0
  82. data/lib/mui/version.rb +1 -1
  83. data/lib/mui/window.rb +158 -0
  84. data/lib/mui/window_manager.rb +249 -0
  85. data/lib/mui.rb +156 -2
  86. metadata +98 -3
@@ -0,0 +1,354 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ module KeyHandler
5
+ # Handles key inputs in Insert mode
6
+ class InsertMode < Base
7
+ def initialize(mode_manager, buffer, undo_manager: nil, group_started: false)
8
+ super(mode_manager, buffer)
9
+ @undo_manager = undo_manager
10
+ # Start undo group unless already started (e.g., by change operator)
11
+ @undo_manager&.begin_group unless group_started
12
+ # Build word cache for fast completion (use active window's buffer)
13
+ @word_cache = BufferWordCache.new(self.buffer)
14
+ end
15
+
16
+ def handle(key)
17
+ # Check plugin keymaps first
18
+ plugin_result = check_plugin_keymap(key, :insert)
19
+ return plugin_result if plugin_result
20
+
21
+ case key
22
+ when KeyCode::ESCAPE
23
+ handle_escape
24
+ when Curses::KEY_LEFT
25
+ handle_move_left
26
+ when Curses::KEY_RIGHT
27
+ handle_move_right
28
+ when Curses::KEY_UP
29
+ completion_active? ? handle_completion_previous : handle_move_up
30
+ when Curses::KEY_DOWN
31
+ completion_active? ? handle_completion_next : handle_move_down
32
+ when KeyCode::CTRL_P
33
+ completion_active? ? handle_completion_previous : handle_buffer_completion
34
+ when KeyCode::CTRL_N
35
+ completion_active? ? handle_completion_next : handle_buffer_completion
36
+ when KeyCode::TAB
37
+ completion_active? ? handle_completion_confirm : handle_tab
38
+ when KeyCode::BACKSPACE, Curses::KEY_BACKSPACE
39
+ handle_backspace
40
+ when KeyCode::ENTER_CR, KeyCode::ENTER_LF, Curses::KEY_ENTER
41
+ handle_enter
42
+ else
43
+ handle_character_input(key)
44
+ end
45
+ end
46
+
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
+ private
86
+
87
+ def handle_escape
88
+ # Cancel completion if active
89
+ editor.insert_completion_state.reset if completion_active?
90
+
91
+ @undo_manager&.end_group
92
+ # Remove trailing whitespace from current line if it's whitespace-only (Vim behavior)
93
+ stripped = strip_trailing_whitespace_if_empty_line
94
+ # Move cursor back one position unless we just stripped whitespace (cursor already at 0)
95
+ self.cursor_col = cursor_col - 1 if cursor_col.positive? && !stripped
96
+ result(mode: Mode::NORMAL)
97
+ end
98
+
99
+ def strip_trailing_whitespace_if_empty_line
100
+ line = buffer.line(cursor_row)
101
+ return false unless line.match?(/\A[ \t]+\z/)
102
+
103
+ # Line contains only whitespace, clear it
104
+ line.length.times { buffer.delete_char(cursor_row, 0) }
105
+ self.cursor_col = 0
106
+ true
107
+ end
108
+
109
+ def handle_move_left
110
+ self.cursor_col = cursor_col - 1 if cursor_col.positive?
111
+ result
112
+ end
113
+
114
+ def handle_move_right
115
+ self.cursor_col = cursor_col + 1 if cursor_col < current_line_length
116
+ result
117
+ end
118
+
119
+ def handle_move_up
120
+ window.move_up
121
+ result
122
+ end
123
+
124
+ def handle_move_down
125
+ window.move_down
126
+ result
127
+ end
128
+
129
+ def handle_backspace
130
+ if cursor_col.positive?
131
+ self.cursor_col = cursor_col - 1
132
+ buffer.delete_char(cursor_row, cursor_col)
133
+ # Update completion list after backspace
134
+ update_completion_list if completion_active?
135
+ elsif cursor_row.positive?
136
+ join_with_previous_line
137
+ editor.insert_completion_state.reset if completion_active?
138
+ end
139
+ result
140
+ end
141
+
142
+ def join_with_previous_line
143
+ prev_line_len = buffer.line(cursor_row - 1).length
144
+ buffer.join_lines(cursor_row - 1)
145
+ self.cursor_row = cursor_row - 1
146
+ self.cursor_col = prev_line_len
147
+ end
148
+
149
+ def handle_enter
150
+ # Get indent from current line before splitting
151
+ current_line = buffer.line(cursor_row)
152
+ indent = extract_indent(current_line)
153
+
154
+ buffer.split_line(cursor_row, cursor_col)
155
+ self.cursor_row = cursor_row + 1
156
+
157
+ # Insert indent at the beginning of the new line
158
+ if indent && !indent.empty?
159
+ indent.each_char.with_index do |char, i|
160
+ buffer.insert_char(cursor_row, i, char)
161
+ end
162
+ self.cursor_col = indent.length
163
+ else
164
+ self.cursor_col = 0
165
+ end
166
+ result
167
+ end
168
+
169
+ def extract_indent(line)
170
+ match = line.match(/\A([ \t]*)/)
171
+ match ? match[1] : ""
172
+ end
173
+
174
+ def handle_character_input(key)
175
+ char = extract_printable_char(key)
176
+ if char
177
+ buffer.insert_char(cursor_row, cursor_col, char)
178
+ self.cursor_col = cursor_col + 1
179
+
180
+ # Update completion list if active
181
+ if completion_active? && word_char?(char)
182
+ update_completion_list
183
+ elsif completion_active?
184
+ # Non-word character typed, close completion and add completed word to cache
185
+ add_current_word_to_cache
186
+ editor.insert_completion_state.reset
187
+ trigger_completion_for(char)
188
+ else
189
+ # Non-word char means previous word is complete, add to cache
190
+ add_current_word_to_cache unless word_char?(char)
191
+ # Trigger completion for certain characters
192
+ trigger_completion_for(char)
193
+ end
194
+ end
195
+ result
196
+ end
197
+
198
+ def add_current_word_to_cache
199
+ # Get the word that just ended (before current cursor)
200
+ line = buffer.line(cursor_row)
201
+ return if cursor_col < 2
202
+
203
+ # Find the word that just ended
204
+ end_col = cursor_col - 1
205
+ start_col = end_col
206
+ start_col -= 1 while start_col.positive? && word_char?(line[start_col - 1])
207
+
208
+ word = line[start_col...end_col]
209
+ @word_cache.add_word(word) if word && word.length >= BufferWordCache::MIN_WORD_LENGTH
210
+ end
211
+
212
+ def update_completion_list
213
+ return unless editor
214
+
215
+ new_prefix = @word_cache.prefix_at(cursor_row, cursor_col)
216
+
217
+ if new_prefix.empty?
218
+ editor.insert_completion_state.reset
219
+ return
220
+ end
221
+
222
+ editor.insert_completion_state.update_prefix(new_prefix)
223
+
224
+ # Close completion if no matches remain
225
+ editor.insert_completion_state.reset unless editor.insert_completion_state.active?
226
+ end
227
+
228
+ def trigger_completion_for(char)
229
+ return unless editor
230
+
231
+ # LSP completion triggers
232
+ if %w[. @].include?(char) || (char == ":" && previous_char == ":")
233
+ editor.trigger_autocmd(:InsertCompletion)
234
+ return
235
+ end
236
+
237
+ # Buffer word completion - trigger after typing word characters
238
+ trigger_buffer_completion_if_needed(min_prefix: 1) if word_char?(char)
239
+ end
240
+
241
+ def trigger_buffer_completion_if_needed(min_prefix: 3)
242
+ prefix = @word_cache.prefix_at(cursor_row, cursor_col)
243
+
244
+ return if prefix.length < min_prefix
245
+
246
+ # Mark current row as dirty to exclude word at cursor
247
+ @word_cache.mark_dirty(cursor_row)
248
+ candidates = @word_cache.complete(prefix, cursor_row, cursor_col)
249
+ return if candidates.empty?
250
+
251
+ # Format candidates for InsertCompletionState
252
+ items = candidates.map do |word|
253
+ {
254
+ label: word,
255
+ insert_text: word
256
+ }
257
+ end
258
+
259
+ editor.start_insert_completion(items, prefix:)
260
+ end
261
+
262
+ def word_char?(char)
263
+ char&.match?(/\w/)
264
+ end
265
+
266
+ def previous_char
267
+ return nil if cursor_col < 2
268
+
269
+ buffer.line(cursor_row)[cursor_col - 2]
270
+ end
271
+
272
+ def handle_buffer_completion
273
+ return result unless editor
274
+
275
+ # For manual trigger (Ctrl+N/P), allow 1+ character prefix
276
+ trigger_buffer_completion_if_needed(min_prefix: 1)
277
+ result
278
+ end
279
+
280
+ def handle_tab
281
+ buffer.insert_char(cursor_row, cursor_col, "\t")
282
+ self.cursor_col = cursor_col + 1
283
+ result
284
+ end
285
+
286
+ def completion_active?
287
+ editor&.insert_completion_active?
288
+ end
289
+
290
+ def handle_completion_next
291
+ editor.insert_completion_state.select_next
292
+ result
293
+ end
294
+
295
+ def handle_completion_previous
296
+ editor.insert_completion_state.select_previous
297
+ result
298
+ end
299
+
300
+ def handle_completion_confirm
301
+ state = editor.insert_completion_state
302
+ return result unless state.current_item
303
+
304
+ insert_text = state.insert_text
305
+ text_edit_range = state.text_edit_range
306
+
307
+ if text_edit_range
308
+ # Use textEdit range for precise replacement
309
+ apply_text_edit(insert_text, text_edit_range)
310
+ else
311
+ # Fallback to prefix-based replacement
312
+ apply_prefix_replacement(insert_text, state.prefix)
313
+ end
314
+
315
+ state.reset
316
+ result
317
+ end
318
+
319
+ def apply_text_edit(insert_text, range)
320
+ start_char = range.dig(:start, :character) || range.dig("start", "character")
321
+ end_char = range.dig(:end, :character) || range.dig("end", "character")
322
+
323
+ # Delete from start to end
324
+ delete_count = end_char - start_char
325
+ self.cursor_col = start_char
326
+ delete_count.times { buffer.delete_char(cursor_row, cursor_col) }
327
+
328
+ # Insert new text
329
+ insert_text.each_char do |c|
330
+ buffer.insert_char(cursor_row, cursor_col, c)
331
+ self.cursor_col = cursor_col + 1
332
+ end
333
+ end
334
+
335
+ def apply_prefix_replacement(insert_text, prefix)
336
+ # Delete prefix
337
+ prefix.length.times do
338
+ self.cursor_col = cursor_col - 1
339
+ buffer.delete_char(cursor_row, cursor_col)
340
+ end
341
+
342
+ # Insert completion text
343
+ insert_text.each_char do |c|
344
+ buffer.insert_char(cursor_row, cursor_col, c)
345
+ self.cursor_col = cursor_col + 1
346
+ end
347
+ end
348
+
349
+ def result(mode: nil, message: nil, quit: false)
350
+ HandlerResult::InsertModeResult.new(mode:, message:, quit:)
351
+ end
352
+ end
353
+ end
354
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ module KeyHandler
5
+ module Motions
6
+ # Shared motion handling for NormalMode and VisualMode
7
+ # Provides common handle_* methods that delegate to Motion module
8
+ module MotionHandler
9
+ def handle_word_forward
10
+ apply_motion(Motion.word_forward(buffer, cursor_row, cursor_col))
11
+ result
12
+ end
13
+
14
+ def handle_word_backward
15
+ apply_motion(Motion.word_backward(buffer, cursor_row, cursor_col))
16
+ result
17
+ end
18
+
19
+ def handle_word_end
20
+ apply_motion(Motion.word_end(buffer, cursor_row, cursor_col))
21
+ result
22
+ end
23
+
24
+ def handle_line_start
25
+ apply_motion(Motion.line_start(buffer, cursor_row, cursor_col))
26
+ result
27
+ end
28
+
29
+ def handle_first_non_blank
30
+ apply_motion(Motion.first_non_blank(buffer, cursor_row, cursor_col))
31
+ result
32
+ end
33
+
34
+ def handle_line_end
35
+ apply_motion(Motion.line_end(buffer, cursor_row, cursor_col))
36
+ result
37
+ end
38
+
39
+ def handle_file_end
40
+ apply_motion(Motion.file_end(buffer, cursor_row, cursor_col))
41
+ result
42
+ end
43
+
44
+ private
45
+
46
+ def apply_motion(motion_result)
47
+ return unless motion_result
48
+
49
+ self.cursor_row = motion_result[:row]
50
+ self.cursor_col = motion_result[:col]
51
+ window.clamp_cursor_to_line(buffer)
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end