mui 0.1.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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +163 -0
- data/CHANGELOG.md +448 -0
- data/README.md +309 -6
- data/docs/_config.yml +56 -0
- data/docs/configuration.md +301 -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 +149 -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 +30 -0
- data/lib/mui/command_context.rb +90 -0
- data/lib/mui/command_history.rb +89 -0
- data/lib/mui/command_line.rb +167 -0
- data/lib/mui/command_registry.rb +44 -0
- data/lib/mui/completion_renderer.rb +84 -0
- data/lib/mui/completion_state.rb +58 -0
- data/lib/mui/config.rb +58 -0
- data/lib/mui/editor.rb +395 -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 +107 -0
- data/lib/mui/highlight.rb +22 -0
- data/lib/mui/highlighters/base.rb +23 -0
- data/lib/mui/highlighters/search_highlighter.rb +27 -0
- data/lib/mui/highlighters/selection_highlighter.rb +48 -0
- data/lib/mui/highlighters/syntax_highlighter.rb +107 -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 +187 -0
- data/lib/mui/key_handler/command_mode.rb +511 -0
- data/lib/mui/key_handler/insert_mode.rb +323 -0
- data/lib/mui/key_handler/motions/motion_handler.rb +56 -0
- data/lib/mui/key_handler/normal_mode.rb +552 -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 +119 -0
- data/lib/mui/key_handler/operators/yank_operator.rb +127 -0
- data/lib/mui/key_handler/search_mode.rb +191 -0
- data/lib/mui/key_handler/visual_line_mode.rb +20 -0
- data/lib/mui/key_handler/visual_mode.rb +402 -0
- data/lib/mui/key_handler/window_command.rb +112 -0
- data/lib/mui/key_handler.rb +16 -0
- data/lib/mui/key_notation_parser.rb +152 -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/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 +173 -0
- data/lib/mui/mode.rb +13 -0
- data/lib/mui/mode_manager.rb +186 -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 +103 -0
- data/lib/mui/search_completer.rb +50 -0
- data/lib/mui/search_input.rb +40 -0
- data/lib/mui/search_state.rb +121 -0
- data/lib/mui/selection.rb +55 -0
- data/lib/mui/status_line_renderer.rb +40 -0
- data/lib/mui/syntax/language_detector.rb +106 -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/css_lexer.rb +121 -0
- data/lib/mui/syntax/lexers/go_lexer.rb +205 -0
- data/lib/mui/syntax/lexers/html_lexer.rb +118 -0
- data/lib/mui/syntax/lexers/javascript_lexer.rb +197 -0
- data/lib/mui/syntax/lexers/markdown_lexer.rb +210 -0
- data/lib/mui/syntax/lexers/ruby_lexer.rb +114 -0
- data/lib/mui/syntax/lexers/rust_lexer.rb +148 -0
- data/lib/mui/syntax/lexers/typescript_lexer.rb +203 -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 +164 -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 +201 -0
- data/lib/mui/window_manager.rb +256 -0
- data/lib/mui/wrap_cache.rb +40 -0
- data/lib/mui/wrap_helper.rb +84 -0
- data/lib/mui.rb +171 -2
- metadata +123 -5
data/lib/mui/editor.rb
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mui
|
|
4
|
+
# Main editor class that coordinates all components
|
|
5
|
+
class Editor
|
|
6
|
+
attr_reader :tab_manager, :undo_manager, :autocmd, :command_registry, :job_manager, :color_scheme, :floating_window,
|
|
7
|
+
:insert_completion_state, :key_sequence_handler
|
|
8
|
+
attr_accessor :message, :running
|
|
9
|
+
|
|
10
|
+
def initialize(file_path = nil, adapter: TerminalAdapter::Curses.new, load_config: true)
|
|
11
|
+
Mui.load_config if load_config
|
|
12
|
+
|
|
13
|
+
@adapter = adapter
|
|
14
|
+
@color_manager = ColorManager.new
|
|
15
|
+
@adapter.color_resolver = @color_manager
|
|
16
|
+
@color_scheme = load_color_scheme
|
|
17
|
+
@screen = Screen.new(adapter: @adapter, color_manager: @color_manager)
|
|
18
|
+
@input = Input.new(adapter: @adapter)
|
|
19
|
+
@buffer = Buffer.new
|
|
20
|
+
@buffer.load(file_path) if file_path
|
|
21
|
+
|
|
22
|
+
@tab_manager = TabManager.new(@screen, color_scheme: @color_scheme)
|
|
23
|
+
initial_tab = @tab_manager.add
|
|
24
|
+
initial_tab.window_manager.add_window(@buffer)
|
|
25
|
+
|
|
26
|
+
@tab_bar_renderer = TabBarRenderer.new(@tab_manager, color_scheme: @color_scheme)
|
|
27
|
+
|
|
28
|
+
@command_line = CommandLine.new
|
|
29
|
+
@completion_renderer = CompletionRenderer.new(@screen, @color_scheme)
|
|
30
|
+
@insert_completion_renderer = InsertCompletionRenderer.new(@screen, @color_scheme)
|
|
31
|
+
@floating_window = FloatingWindow.new(@color_scheme)
|
|
32
|
+
@message = nil
|
|
33
|
+
@running = true
|
|
34
|
+
|
|
35
|
+
@undo_manager = UndoManager.new
|
|
36
|
+
@buffer.undo_manager = @undo_manager
|
|
37
|
+
|
|
38
|
+
@autocmd = Autocmd.new
|
|
39
|
+
@command_registry = CommandRegistry.new
|
|
40
|
+
@job_manager = JobManager.new(autocmd: @autocmd)
|
|
41
|
+
@insert_completion_state = InsertCompletionState.new
|
|
42
|
+
|
|
43
|
+
# Install and load plugins via bundler/inline
|
|
44
|
+
Mui.plugin_manager.install_and_load
|
|
45
|
+
|
|
46
|
+
# Load plugin autocmds
|
|
47
|
+
load_plugin_autocmds
|
|
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
|
+
|
|
53
|
+
@mode_manager = ModeManager.new(
|
|
54
|
+
window: @tab_manager,
|
|
55
|
+
buffer: @buffer,
|
|
56
|
+
command_line: @command_line,
|
|
57
|
+
undo_manager: @undo_manager,
|
|
58
|
+
editor: self,
|
|
59
|
+
key_sequence_handler: @key_sequence_handler
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Trigger BufEnter event
|
|
63
|
+
trigger_autocmd(:BufEnter)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def window_manager
|
|
67
|
+
@tab_manager.window_manager
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def window
|
|
71
|
+
@tab_manager.active_window
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def buffer
|
|
75
|
+
window.buffer
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def mode
|
|
79
|
+
@mode_manager.mode
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def selection
|
|
83
|
+
@mode_manager.selection
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def register
|
|
87
|
+
@mode_manager.register
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def run
|
|
91
|
+
while @running
|
|
92
|
+
process_job_results
|
|
93
|
+
check_key_sequence_timeout
|
|
94
|
+
update_window_size
|
|
95
|
+
render
|
|
96
|
+
key = @input.read_nonblock
|
|
97
|
+
if key
|
|
98
|
+
handle_key(key)
|
|
99
|
+
else
|
|
100
|
+
sleep 0.01 # CPU usage optimization
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
ensure
|
|
104
|
+
@adapter.close
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Open a scratch buffer (read-only) with the given content
|
|
108
|
+
def open_scratch_buffer(name, content)
|
|
109
|
+
scratch_buffer = Buffer.new(name)
|
|
110
|
+
scratch_buffer.content = content
|
|
111
|
+
scratch_buffer.readonly = true
|
|
112
|
+
|
|
113
|
+
# Split horizontally and show the scratch buffer
|
|
114
|
+
window_manager.split_horizontal(scratch_buffer)
|
|
115
|
+
end
|
|
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
|
+
|
|
153
|
+
# Suspend UI for running external interactive commands (e.g., fzf)
|
|
154
|
+
def suspend_ui
|
|
155
|
+
@adapter.suspend
|
|
156
|
+
yield
|
|
157
|
+
ensure
|
|
158
|
+
@adapter.resume
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def handle_key(key)
|
|
162
|
+
@message = nil
|
|
163
|
+
|
|
164
|
+
# Close floating window on Escape or any key input (except scroll keys if we add them)
|
|
165
|
+
if @floating_window.visible
|
|
166
|
+
@floating_window.hide
|
|
167
|
+
return if key == KeyCode::ESCAPE
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
old_window = window
|
|
171
|
+
old_buffer = old_window&.buffer
|
|
172
|
+
old_modified = old_buffer&.modified
|
|
173
|
+
result = @mode_manager.current_handler.handle(key)
|
|
174
|
+
apply_result(result)
|
|
175
|
+
|
|
176
|
+
current_window = window
|
|
177
|
+
return unless current_window # Guard against nil window (e.g., after closing last tab)
|
|
178
|
+
|
|
179
|
+
current_buffer = current_window.buffer
|
|
180
|
+
|
|
181
|
+
# Trigger BufEnter if buffer changed (window focus change)
|
|
182
|
+
trigger_autocmd(:BufEnter) if current_buffer != old_buffer
|
|
183
|
+
|
|
184
|
+
# Trigger TextChanged if buffer was modified
|
|
185
|
+
trigger_autocmd(:TextChanged) if (current_buffer.modified && !old_modified) || buffer_content_changed?
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Trigger autocmd event with current context
|
|
189
|
+
def trigger_autocmd(event)
|
|
190
|
+
context = CommandContext.new(editor: self, buffer:, window:)
|
|
191
|
+
@autocmd.trigger(event, context)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Show a floating window with content at the cursor position
|
|
195
|
+
def show_floating(content, max_width: nil, max_height: nil)
|
|
196
|
+
# Position below cursor
|
|
197
|
+
row = window.screen_cursor_y + 1
|
|
198
|
+
col = window.screen_cursor_x
|
|
199
|
+
|
|
200
|
+
@floating_window.show(
|
|
201
|
+
content,
|
|
202
|
+
row:,
|
|
203
|
+
col:,
|
|
204
|
+
max_width: max_width || (@screen.width / 2),
|
|
205
|
+
max_height: max_height || 10
|
|
206
|
+
)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Hide the floating window
|
|
210
|
+
def hide_floating
|
|
211
|
+
@floating_window.hide
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Start insert mode completion with LSP items
|
|
215
|
+
def start_insert_completion(items, prefix: "")
|
|
216
|
+
@insert_completion_state.start(items, prefix:)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Check if insert completion is active
|
|
220
|
+
def insert_completion_active?
|
|
221
|
+
@insert_completion_state.active?
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
private
|
|
225
|
+
|
|
226
|
+
def buffer_content_changed?
|
|
227
|
+
# Track if content actually changed (for TextChanged event)
|
|
228
|
+
# Use change_count instead of hash for O(1) performance
|
|
229
|
+
@last_change_count ||= buffer.change_count
|
|
230
|
+
current_count = buffer.change_count
|
|
231
|
+
changed = @last_change_count != current_count
|
|
232
|
+
@last_change_count = current_count
|
|
233
|
+
changed
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def update_window_size
|
|
237
|
+
window_manager.update_layout(y_offset: tab_bar_height)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def render
|
|
241
|
+
@screen.clear
|
|
242
|
+
|
|
243
|
+
@tab_bar_renderer.render(@screen, 0)
|
|
244
|
+
|
|
245
|
+
window.ensure_cursor_visible
|
|
246
|
+
window_manager.render_all(
|
|
247
|
+
@screen,
|
|
248
|
+
selection: @mode_manager.selection,
|
|
249
|
+
search_state: @mode_manager.search_state
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
render_status_area
|
|
253
|
+
|
|
254
|
+
# Position cursor based on current mode
|
|
255
|
+
if @mode_manager.mode == Mode::COMMAND
|
|
256
|
+
# In command mode, cursor is on the command line (after ":" + buffer position)
|
|
257
|
+
@screen.move_cursor(@screen.height - 1, 1 + @command_line.cursor_pos)
|
|
258
|
+
elsif [Mode::SEARCH_FORWARD, Mode::SEARCH_BACKWARD].include?(@mode_manager.mode)
|
|
259
|
+
# In search mode, cursor is on the search input line (after "/" or "?" + pattern)
|
|
260
|
+
@screen.move_cursor(@screen.height - 1, 1 + @mode_manager.search_input.buffer.length)
|
|
261
|
+
else
|
|
262
|
+
# In other modes, cursor is in the editor window
|
|
263
|
+
@screen.move_cursor(window.screen_cursor_y, window.screen_cursor_x)
|
|
264
|
+
end
|
|
265
|
+
@screen.refresh
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def tab_bar_height
|
|
269
|
+
@tab_bar_renderer.height
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def render_status_area
|
|
273
|
+
status_text = case @mode_manager.mode
|
|
274
|
+
when Mode::COMMAND
|
|
275
|
+
@command_line.to_s
|
|
276
|
+
when Mode::INSERT
|
|
277
|
+
@message || "-- INSERT --"
|
|
278
|
+
when Mode::VISUAL
|
|
279
|
+
@message || "-- VISUAL --"
|
|
280
|
+
when Mode::VISUAL_LINE
|
|
281
|
+
@message || "-- VISUAL LINE --"
|
|
282
|
+
when Mode::SEARCH_FORWARD, Mode::SEARCH_BACKWARD
|
|
283
|
+
@mode_manager.search_input.to_s
|
|
284
|
+
else
|
|
285
|
+
@message || "-- NORMAL --"
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
status_line = status_text.ljust(@screen.width)
|
|
289
|
+
style = @color_scheme[:command_line]
|
|
290
|
+
@screen.put_with_style(@screen.height - 1, 0, status_line, style)
|
|
291
|
+
|
|
292
|
+
# Render completion popup based on mode
|
|
293
|
+
if @mode_manager.mode == Mode::COMMAND
|
|
294
|
+
render_completion_popup
|
|
295
|
+
elsif [Mode::SEARCH_FORWARD, Mode::SEARCH_BACKWARD].include?(@mode_manager.mode)
|
|
296
|
+
render_search_completion_popup
|
|
297
|
+
elsif @mode_manager.mode == Mode::INSERT
|
|
298
|
+
render_insert_completion_popup
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Render floating window if visible
|
|
302
|
+
@floating_window.render(@screen)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def render_search_completion_popup
|
|
306
|
+
completion_state = @mode_manager.current_handler.completion_state
|
|
307
|
+
return unless completion_state&.active?
|
|
308
|
+
|
|
309
|
+
# Popup appears above the search line, starting after "/" or "?"
|
|
310
|
+
base_row = @screen.height - 1
|
|
311
|
+
base_col = 1 # After the prompt
|
|
312
|
+
@completion_renderer.render(completion_state, base_row, base_col)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def render_completion_popup
|
|
316
|
+
completion_state = @mode_manager.current_handler.completion_state
|
|
317
|
+
return unless completion_state&.active?
|
|
318
|
+
|
|
319
|
+
# Popup appears above the command line, starting after the ":"
|
|
320
|
+
base_row = @screen.height - 1
|
|
321
|
+
base_col = 1 # After the ":"
|
|
322
|
+
@completion_renderer.render(completion_state, base_row, base_col)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def render_insert_completion_popup
|
|
326
|
+
return unless @insert_completion_state&.active?
|
|
327
|
+
|
|
328
|
+
# Popup appears below the cursor
|
|
329
|
+
@insert_completion_renderer.render(
|
|
330
|
+
@insert_completion_state,
|
|
331
|
+
window.screen_cursor_y,
|
|
332
|
+
window.screen_cursor_x
|
|
333
|
+
)
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def apply_result(result)
|
|
337
|
+
@mode_manager.transition(result)
|
|
338
|
+
@message = result.message if result.message
|
|
339
|
+
@running = false if result.quit?
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def load_color_scheme
|
|
343
|
+
scheme_name = Mui.config.get(:colorscheme)
|
|
344
|
+
Themes.send(scheme_name.to_sym)
|
|
345
|
+
rescue NoMethodError
|
|
346
|
+
Themes.mui
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def load_plugin_autocmds
|
|
350
|
+
Mui.config.autocmds.each do |event, handlers|
|
|
351
|
+
handlers.each do |h|
|
|
352
|
+
@autocmd.register(event, pattern: h[:pattern], &h[:handler])
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def process_job_results
|
|
358
|
+
@job_manager.poll
|
|
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
|
|
394
|
+
end
|
|
395
|
+
end
|
data/lib/mui/error.rb
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mui
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
# Raised when a subclass does not override a required method
|
|
7
|
+
class MethodNotOverriddenError < Error
|
|
8
|
+
def initialize(method_name)
|
|
9
|
+
super("Subclass must implement ##{method_name}")
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Raised when a plugin operation fails
|
|
14
|
+
class PluginError < Error; end
|
|
15
|
+
|
|
16
|
+
# Raised when a plugin is not found
|
|
17
|
+
class PluginNotFoundError < PluginError
|
|
18
|
+
def initialize(plugin_name)
|
|
19
|
+
super("Plugin '#{plugin_name}' not found")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Raised when an unknown command is executed
|
|
24
|
+
class UnknownCommandError < Error
|
|
25
|
+
def initialize(command_name)
|
|
26
|
+
super("Unknown command: #{command_name}")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mui
|
|
4
|
+
# Provides file path completion
|
|
5
|
+
class FileCompleter
|
|
6
|
+
def complete(partial_path)
|
|
7
|
+
return list_current_directory if partial_path.empty?
|
|
8
|
+
|
|
9
|
+
dir, prefix = split_path(partial_path)
|
|
10
|
+
entries = list_directory(dir)
|
|
11
|
+
|
|
12
|
+
entries.select { |entry| entry.start_with?(prefix) }
|
|
13
|
+
.map { |entry| join_path(dir, entry) }
|
|
14
|
+
.map { |path| format_path(path) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def split_path(path)
|
|
20
|
+
if path.end_with?("/")
|
|
21
|
+
[path, ""]
|
|
22
|
+
else
|
|
23
|
+
dir = File.dirname(path)
|
|
24
|
+
dir = "" if dir == "."
|
|
25
|
+
[dir, File.basename(path)]
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def list_directory(dir)
|
|
30
|
+
target = dir.empty? ? "." : dir
|
|
31
|
+
return [] unless Dir.exist?(target)
|
|
32
|
+
|
|
33
|
+
Dir.entries(target)
|
|
34
|
+
.reject { |e| e.start_with?(".") }
|
|
35
|
+
.sort
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def list_current_directory
|
|
39
|
+
list_directory("").map { |entry| format_path(entry) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def join_path(dir, entry)
|
|
43
|
+
dir.empty? ? entry : File.join(dir, entry)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def format_path(path)
|
|
47
|
+
full_path = path.start_with?("/") ? path : File.join(".", path)
|
|
48
|
+
File.directory?(full_path) ? "#{path}/" : path
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mui
|
|
4
|
+
# A floating window (popup) for displaying temporary content like hover info
|
|
5
|
+
class FloatingWindow
|
|
6
|
+
attr_reader :content, :row, :col, :width, :height
|
|
7
|
+
attr_accessor :visible
|
|
8
|
+
|
|
9
|
+
def initialize(color_scheme)
|
|
10
|
+
@color_scheme = color_scheme
|
|
11
|
+
@content = []
|
|
12
|
+
@row = 0
|
|
13
|
+
@col = 0
|
|
14
|
+
@width = 0
|
|
15
|
+
@height = 0
|
|
16
|
+
@visible = false
|
|
17
|
+
@scroll_offset = 0
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Show the floating window with content at the specified position
|
|
21
|
+
def show(content, row:, col:, max_width: nil, max_height: nil)
|
|
22
|
+
@content = normalize_content(content)
|
|
23
|
+
@row = row
|
|
24
|
+
@col = col
|
|
25
|
+
@max_width = max_width
|
|
26
|
+
@max_height = max_height
|
|
27
|
+
@scroll_offset = 0
|
|
28
|
+
calculate_dimensions
|
|
29
|
+
@visible = true
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Hide the floating window
|
|
33
|
+
def hide
|
|
34
|
+
@visible = false
|
|
35
|
+
@content = []
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Scroll content up
|
|
39
|
+
def scroll_up
|
|
40
|
+
@scroll_offset = [@scroll_offset - 1, 0].max if @visible
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Scroll content down
|
|
44
|
+
def scroll_down
|
|
45
|
+
max_offset = [@content.length - @height, 0].max
|
|
46
|
+
@scroll_offset = [@scroll_offset + 1, max_offset].min if @visible
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Render the floating window to the screen
|
|
50
|
+
def render(screen)
|
|
51
|
+
return unless @visible
|
|
52
|
+
return if @content.empty?
|
|
53
|
+
|
|
54
|
+
# Adjust position to fit within screen bounds
|
|
55
|
+
adjusted_row, adjusted_col = adjust_position(screen)
|
|
56
|
+
|
|
57
|
+
# Draw border and content
|
|
58
|
+
draw_border(screen, adjusted_row, adjusted_col)
|
|
59
|
+
draw_content(screen, adjusted_row, adjusted_col)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def normalize_content(content)
|
|
65
|
+
case content
|
|
66
|
+
when String
|
|
67
|
+
content.split("\n")
|
|
68
|
+
when Array
|
|
69
|
+
content.flat_map { |line| line.to_s.split("\n") }
|
|
70
|
+
else
|
|
71
|
+
[content.to_s]
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def calculate_dimensions
|
|
76
|
+
return if @content.empty?
|
|
77
|
+
|
|
78
|
+
# Calculate content dimensions
|
|
79
|
+
content_width = @content.map { |line| UnicodeWidth.string_width(line) }.max || 0
|
|
80
|
+
content_height = @content.length
|
|
81
|
+
|
|
82
|
+
# Apply max constraints (+2 for border)
|
|
83
|
+
@width = content_width + 2
|
|
84
|
+
@width = [@width, @max_width].min if @max_width
|
|
85
|
+
|
|
86
|
+
@height = content_height + 2
|
|
87
|
+
@height = [@height, @max_height].min if @max_height
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def adjust_position(screen)
|
|
91
|
+
row = @row
|
|
92
|
+
col = @col
|
|
93
|
+
|
|
94
|
+
# Adjust horizontal position
|
|
95
|
+
col = screen.width - @width if col + @width > screen.width
|
|
96
|
+
col = [col, 0].max
|
|
97
|
+
|
|
98
|
+
# Adjust vertical position - prefer below cursor, but go above if not enough space
|
|
99
|
+
if row + @height > screen.height
|
|
100
|
+
# Try above the original position
|
|
101
|
+
row = @row - @height
|
|
102
|
+
end
|
|
103
|
+
row = [row, 0].max
|
|
104
|
+
|
|
105
|
+
[row, col]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def draw_border(screen, row, col)
|
|
109
|
+
style = @color_scheme[:floating_window] || @color_scheme[:completion_popup]
|
|
110
|
+
|
|
111
|
+
# Top border
|
|
112
|
+
top_border = "┌#{"─" * (@width - 2)}┐"
|
|
113
|
+
screen.put_with_style(row, col, top_border, style)
|
|
114
|
+
|
|
115
|
+
# Side borders
|
|
116
|
+
inner_height = @height - 2
|
|
117
|
+
inner_height.times do |i|
|
|
118
|
+
screen.put_with_style(row + 1 + i, col, "│", style)
|
|
119
|
+
screen.put_with_style(row + 1 + i, col + @width - 1, "│", style)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Bottom border
|
|
123
|
+
bottom_border = "└#{"─" * (@width - 2)}┘"
|
|
124
|
+
screen.put_with_style(row + @height - 1, col, bottom_border, style)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def draw_content(screen, row, col)
|
|
128
|
+
style = @color_scheme[:floating_window] || @color_scheme[:completion_popup]
|
|
129
|
+
inner_width = @width - 2
|
|
130
|
+
inner_height = @height - 2
|
|
131
|
+
|
|
132
|
+
inner_height.times do |i|
|
|
133
|
+
line_index = @scroll_offset + i
|
|
134
|
+
line = @content[line_index] || ""
|
|
135
|
+
|
|
136
|
+
# Truncate line if needed
|
|
137
|
+
display_line = truncate_to_width(line, inner_width)
|
|
138
|
+
padded_line = display_line.ljust(inner_width)
|
|
139
|
+
|
|
140
|
+
screen.put_with_style(row + 1 + i, col + 1, padded_line, style)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def truncate_to_width(text, max_width)
|
|
145
|
+
return text if UnicodeWidth.string_width(text) <= max_width
|
|
146
|
+
|
|
147
|
+
result = ""
|
|
148
|
+
current_width = 0
|
|
149
|
+
|
|
150
|
+
text.each_char do |char|
|
|
151
|
+
char_width = UnicodeWidth.char_width(char)
|
|
152
|
+
break if current_width + char_width > max_width
|
|
153
|
+
|
|
154
|
+
result += char
|
|
155
|
+
current_width += char_width
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
result
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|