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/buffer.rb
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mui
|
|
4
|
+
class Buffer
|
|
5
|
+
attr_reader :lines, :change_count
|
|
6
|
+
attr_accessor :name, :modified, :undo_manager, :readonly
|
|
7
|
+
|
|
8
|
+
# Alias for autocmd pattern matching
|
|
9
|
+
alias file_path name
|
|
10
|
+
|
|
11
|
+
def initialize(name = "[No Name]")
|
|
12
|
+
@name = name
|
|
13
|
+
@lines = [empty_line]
|
|
14
|
+
@modified = false
|
|
15
|
+
@undo_manager = nil
|
|
16
|
+
@readonly = false
|
|
17
|
+
@custom_highlighter_map = {}
|
|
18
|
+
@change_count = 0
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def readonly?
|
|
22
|
+
@readonly
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Override in subclasses to provide buffer-specific highlighters
|
|
26
|
+
def custom_highlighters(_color_scheme)
|
|
27
|
+
@custom_highlighter_map.values
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Add a custom highlighter with a unique key
|
|
31
|
+
def add_custom_highlighter(key, highlighter)
|
|
32
|
+
@custom_highlighter_map[key] = highlighter
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Remove a custom highlighter by key
|
|
36
|
+
def remove_custom_highlighter(key)
|
|
37
|
+
@custom_highlighter_map.delete(key)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Check if a custom highlighter exists
|
|
41
|
+
def custom_highlighter?(key)
|
|
42
|
+
@custom_highlighter_map.key?(key)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Set content directly (for scratch buffers)
|
|
46
|
+
def content=(text)
|
|
47
|
+
@lines = text.split("\n", -1)
|
|
48
|
+
@lines = [empty_line] if @lines.empty?
|
|
49
|
+
@modified = false
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Reload file from disk
|
|
53
|
+
def reload
|
|
54
|
+
return unless @name && File.exist?(@name)
|
|
55
|
+
|
|
56
|
+
load(@name)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def load(path)
|
|
60
|
+
@name = path
|
|
61
|
+
if File.exist?(path)
|
|
62
|
+
@lines = File.readlines(path, chomp: true)
|
|
63
|
+
@lines = [empty_line] if @lines.empty?
|
|
64
|
+
end
|
|
65
|
+
@modified = false
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def save(path = @name)
|
|
69
|
+
File.write(path, "#{@lines.join("\n")}\n")
|
|
70
|
+
@name = path
|
|
71
|
+
@modified = false
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def line_count
|
|
75
|
+
@lines.size
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def line(n)
|
|
79
|
+
@lines[n] || ""
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def insert_char(row, col, char)
|
|
83
|
+
with_undo(InsertCharAction.new(row, col, char)) do
|
|
84
|
+
insert_char_without_record(row, col, char)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def delete_char(row, col)
|
|
89
|
+
return unless valid_char_position?(row, col)
|
|
90
|
+
|
|
91
|
+
char = @lines[row][col]
|
|
92
|
+
with_undo(DeleteCharAction.new(row, col, char)) do
|
|
93
|
+
delete_char_without_record(row, col)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def insert_line(row, text = nil)
|
|
98
|
+
actual_text = text&.dup || empty_line
|
|
99
|
+
with_undo(InsertLineAction.new(row, actual_text)) do
|
|
100
|
+
insert_line_without_record(row, actual_text)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def delete_line(row)
|
|
105
|
+
text = @lines[row]&.dup
|
|
106
|
+
return unless text
|
|
107
|
+
|
|
108
|
+
with_undo(DeleteLineAction.new(row, text)) do
|
|
109
|
+
delete_line_without_record(row)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def split_line(row, col)
|
|
114
|
+
return unless @lines[row]
|
|
115
|
+
|
|
116
|
+
with_undo(SplitLineAction.new(row, col)) do
|
|
117
|
+
split_line_without_record(row, col)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def join_lines(row)
|
|
122
|
+
return if row >= line_count - 1
|
|
123
|
+
|
|
124
|
+
col = @lines[row]&.size || 0
|
|
125
|
+
with_undo(JoinLinesAction.new(row, col)) do
|
|
126
|
+
join_lines_without_record(row)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def delete_range(start_row, start_col, end_row, end_col)
|
|
131
|
+
deleted_lines = capture_range(start_row, start_col, end_row, end_col)
|
|
132
|
+
with_undo(DeleteRangeAction.new(start_row, start_col, end_row, end_col, deleted_lines)) do
|
|
133
|
+
delete_range_without_record(start_row, start_col, end_row, end_col)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def replace_line(row, text)
|
|
138
|
+
old_text = @lines[row]&.dup || empty_line
|
|
139
|
+
with_undo(ReplaceLineAction.new(row, old_text, text.dup)) do
|
|
140
|
+
replace_line_without_record(row, text)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def insert_char_without_record(row, col, char)
|
|
145
|
+
@lines[row] ||= empty_line
|
|
146
|
+
@lines[row].insert(col, char)
|
|
147
|
+
@modified = true
|
|
148
|
+
@change_count += 1
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def delete_char_without_record(row, col)
|
|
152
|
+
return unless valid_char_position?(row, col)
|
|
153
|
+
|
|
154
|
+
@lines[row].slice!(col)
|
|
155
|
+
@modified = true
|
|
156
|
+
@change_count += 1
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def insert_line_without_record(row, text = nil)
|
|
160
|
+
@lines.insert(row, text&.dup || empty_line)
|
|
161
|
+
@modified = true
|
|
162
|
+
@change_count += 1
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def delete_line_without_record(row)
|
|
166
|
+
@lines.delete_at(row)
|
|
167
|
+
@lines = [empty_line] if @lines.empty?
|
|
168
|
+
@modified = true
|
|
169
|
+
@change_count += 1
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def split_line_without_record(row, col)
|
|
173
|
+
return unless @lines[row]
|
|
174
|
+
|
|
175
|
+
rest = (@lines[row][col..] || "").dup
|
|
176
|
+
@lines[row] = (@lines[row][0...col] || "").dup
|
|
177
|
+
insert_line_without_record(row + 1, rest)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def join_lines_without_record(row)
|
|
181
|
+
return if row >= line_count - 1
|
|
182
|
+
|
|
183
|
+
@lines[row] = ((@lines[row] || "") + (@lines[row + 1] || "")).dup
|
|
184
|
+
delete_line_without_record(row + 1)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def delete_range_without_record(start_row, start_col, end_row, end_col)
|
|
188
|
+
if start_row == end_row
|
|
189
|
+
delete_within_line(start_row, start_col, end_col)
|
|
190
|
+
else
|
|
191
|
+
delete_across_lines(start_row, start_col, end_row, end_col)
|
|
192
|
+
end
|
|
193
|
+
@modified = true
|
|
194
|
+
@change_count += 1
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def replace_line_without_record(row, text)
|
|
198
|
+
@lines[row] = text.dup
|
|
199
|
+
@modified = true
|
|
200
|
+
@change_count += 1
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def restore_range(start_row, start_col, deleted_lines)
|
|
204
|
+
return if deleted_lines.empty?
|
|
205
|
+
|
|
206
|
+
current_line = @lines[start_row] || empty_line
|
|
207
|
+
if deleted_lines.size == 1
|
|
208
|
+
# Single line restore: insert at start_col
|
|
209
|
+
@lines[start_row] = current_line[0...start_col] + deleted_lines[0] + current_line[start_col..]
|
|
210
|
+
else
|
|
211
|
+
# Multi-line restore
|
|
212
|
+
first_part = current_line[0...start_col]
|
|
213
|
+
last_part = current_line[start_col..]
|
|
214
|
+
|
|
215
|
+
# Set first line
|
|
216
|
+
@lines[start_row] = first_part + deleted_lines[0]
|
|
217
|
+
|
|
218
|
+
# Insert middle lines
|
|
219
|
+
(1...(deleted_lines.size - 1)).each do |i|
|
|
220
|
+
@lines.insert(start_row + i, deleted_lines[i].dup)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Insert last line with remainder
|
|
224
|
+
last_deleted = deleted_lines[-1]
|
|
225
|
+
@lines.insert(start_row + deleted_lines.size - 1, last_deleted + last_part)
|
|
226
|
+
end
|
|
227
|
+
@modified = true
|
|
228
|
+
@change_count += 1
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
private
|
|
232
|
+
|
|
233
|
+
def with_undo(action)
|
|
234
|
+
@undo_manager&.record(action)
|
|
235
|
+
yield
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def valid_char_position?(row, col)
|
|
239
|
+
!col.negative? && @lines[row] && col < @lines[row].size
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def empty_line
|
|
243
|
+
String.new
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def capture_range(start_row, start_col, end_row, end_col)
|
|
247
|
+
if start_row == end_row
|
|
248
|
+
line = @lines[start_row] || ""
|
|
249
|
+
[line[start_col..end_col] || ""]
|
|
250
|
+
else
|
|
251
|
+
lines = []
|
|
252
|
+
lines << ((@lines[start_row] || "")[start_col..] || "")
|
|
253
|
+
((start_row + 1)...end_row).each do |row|
|
|
254
|
+
lines << (@lines[row]&.dup || "")
|
|
255
|
+
end
|
|
256
|
+
lines << ((@lines[end_row] || "")[0..end_col] || "")
|
|
257
|
+
lines
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def delete_within_line(row, start_col, end_col)
|
|
262
|
+
line = @lines[row] || ""
|
|
263
|
+
@lines[row] = (line[0...start_col] || "") + (line[(end_col + 1)..] || "")
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def delete_across_lines(start_row, start_col, end_row, end_col)
|
|
267
|
+
first_part = (@lines[start_row] || "")[0...start_col] || ""
|
|
268
|
+
last_part = (@lines[end_row] || "")[(end_col + 1)..] || ""
|
|
269
|
+
|
|
270
|
+
(end_row - start_row).times { @lines.delete_at(start_row + 1) }
|
|
271
|
+
|
|
272
|
+
@lines[start_row] = first_part + last_part
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mui
|
|
4
|
+
# Caches words from buffer for fast completion
|
|
5
|
+
# Built once when entering Insert mode, updated incrementally on changes
|
|
6
|
+
class BufferWordCache
|
|
7
|
+
# Minimum word length to include in cache
|
|
8
|
+
MIN_WORD_LENGTH = 2
|
|
9
|
+
# Maximum candidates to return (for performance)
|
|
10
|
+
MAX_CANDIDATES = 50
|
|
11
|
+
|
|
12
|
+
def initialize(buffer)
|
|
13
|
+
@buffer = buffer
|
|
14
|
+
@words = Set.new
|
|
15
|
+
@dirty_rows = Set.new
|
|
16
|
+
build_cache
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Get completion candidates matching the prefix
|
|
20
|
+
def complete(prefix, cursor_row, cursor_col)
|
|
21
|
+
return [] if prefix.empty?
|
|
22
|
+
|
|
23
|
+
# Process any dirty rows first
|
|
24
|
+
process_dirty_rows(cursor_row, cursor_col)
|
|
25
|
+
|
|
26
|
+
# Get word at cursor to exclude it
|
|
27
|
+
word_at_cursor = word_at_position(cursor_row, cursor_col)
|
|
28
|
+
|
|
29
|
+
prefix_downcase = prefix.downcase
|
|
30
|
+
candidates = @words.select do |w|
|
|
31
|
+
w != prefix && w != word_at_cursor && w.downcase.start_with?(prefix_downcase)
|
|
32
|
+
end
|
|
33
|
+
candidates.to_a.sort.first(MAX_CANDIDATES)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Get the full word at cursor position (for exclusion)
|
|
37
|
+
def word_at_position(row, col)
|
|
38
|
+
line = @buffer.line(row)
|
|
39
|
+
return nil if line.empty?
|
|
40
|
+
|
|
41
|
+
# Find word boundaries
|
|
42
|
+
start_col = col
|
|
43
|
+
start_col -= 1 while start_col.positive? && word_char?(line[start_col - 1])
|
|
44
|
+
|
|
45
|
+
end_col = col
|
|
46
|
+
end_col += 1 while end_col < line.length && word_char?(line[end_col])
|
|
47
|
+
|
|
48
|
+
word = line[start_col...end_col]
|
|
49
|
+
word && word.length >= MIN_WORD_LENGTH ? word : nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Mark a row as dirty (needs re-scanning)
|
|
53
|
+
def mark_dirty(row)
|
|
54
|
+
@dirty_rows << row
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Add a word directly (for incremental updates)
|
|
58
|
+
def add_word(word)
|
|
59
|
+
@words << word if word.length >= MIN_WORD_LENGTH
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Get the word prefix at cursor position
|
|
63
|
+
def prefix_at(row, col)
|
|
64
|
+
line = @buffer.line(row)
|
|
65
|
+
return "" if col.zero? || line.empty?
|
|
66
|
+
|
|
67
|
+
start_col = col
|
|
68
|
+
start_col -= 1 while start_col.positive? && word_char?(line[start_col - 1])
|
|
69
|
+
|
|
70
|
+
line[start_col...col] || ""
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
# Regex to match word characters (alphanumeric + underscore)
|
|
76
|
+
WORD_REGEX = /\w+/
|
|
77
|
+
|
|
78
|
+
def build_cache
|
|
79
|
+
@buffer.lines.each do |line|
|
|
80
|
+
extract_words_fast(line)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def process_dirty_rows(exclude_row, exclude_col)
|
|
85
|
+
return if @dirty_rows.empty?
|
|
86
|
+
|
|
87
|
+
@dirty_rows.each do |row|
|
|
88
|
+
next if row >= @buffer.line_count
|
|
89
|
+
|
|
90
|
+
line = @buffer.line(row)
|
|
91
|
+
extract_words_with_exclusion(line, exclude_row, exclude_col, row)
|
|
92
|
+
end
|
|
93
|
+
@dirty_rows.clear
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Fast word extraction using scan (for initial cache build)
|
|
97
|
+
def extract_words_fast(line)
|
|
98
|
+
line.scan(WORD_REGEX) do |word|
|
|
99
|
+
@words << word if word.length >= MIN_WORD_LENGTH
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Word extraction with cursor position exclusion (for dirty row processing)
|
|
104
|
+
def extract_words_with_exclusion(line, exclude_row, exclude_col, current_row)
|
|
105
|
+
line.scan(WORD_REGEX) do |word|
|
|
106
|
+
next if word.length < MIN_WORD_LENGTH
|
|
107
|
+
|
|
108
|
+
# Get match position
|
|
109
|
+
match = Regexp.last_match
|
|
110
|
+
word_start = match.begin(0)
|
|
111
|
+
word_end = match.end(0)
|
|
112
|
+
|
|
113
|
+
# Skip word at cursor position
|
|
114
|
+
next if exclude_row && current_row == exclude_row && word_start <= exclude_col && word_end > exclude_col
|
|
115
|
+
|
|
116
|
+
@words << word
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def word_char?(char)
|
|
121
|
+
# Direct character check is faster than regex for single chars
|
|
122
|
+
return false unless char
|
|
123
|
+
|
|
124
|
+
c = char.ord
|
|
125
|
+
c.between?(48, 57) || # 0-9
|
|
126
|
+
c.between?(65, 90) || # A-Z
|
|
127
|
+
c.between?(97, 122) || # a-z
|
|
128
|
+
c == 95 # _
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mui
|
|
4
|
+
# Collects words from buffer for completion
|
|
5
|
+
class BufferWordCompleter
|
|
6
|
+
# Minimum word length to include in completion
|
|
7
|
+
MIN_WORD_LENGTH = 2
|
|
8
|
+
# Maximum candidates to collect (for performance)
|
|
9
|
+
MAX_CANDIDATES = 50
|
|
10
|
+
|
|
11
|
+
def initialize(buffer)
|
|
12
|
+
@buffer = buffer
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Get completion candidates matching the prefix
|
|
16
|
+
# Optimized to filter while collecting, stopping early when enough candidates found
|
|
17
|
+
def complete(prefix, cursor_row, cursor_col)
|
|
18
|
+
return [] if prefix.empty?
|
|
19
|
+
|
|
20
|
+
candidates = Set.new
|
|
21
|
+
prefix_downcase = prefix.downcase
|
|
22
|
+
|
|
23
|
+
@buffer.lines.each_with_index do |line, row|
|
|
24
|
+
extract_matching_words(line, row, cursor_row, cursor_col, prefix, prefix_downcase, candidates)
|
|
25
|
+
break if candidates.size >= MAX_CANDIDATES
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
candidates.to_a.sort
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Get the word prefix at cursor position
|
|
32
|
+
def prefix_at(row, col)
|
|
33
|
+
line = @buffer.line(row)
|
|
34
|
+
return "" if col.zero? || line.empty?
|
|
35
|
+
|
|
36
|
+
# Find word start
|
|
37
|
+
start_col = col
|
|
38
|
+
start_col -= 1 while start_col.positive? && word_char?(line[start_col - 1])
|
|
39
|
+
|
|
40
|
+
line[start_col...col] || ""
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def extract_matching_words(line, row, exclude_row, exclude_col, prefix, prefix_downcase, candidates)
|
|
46
|
+
current_word = +""
|
|
47
|
+
word_start = 0
|
|
48
|
+
|
|
49
|
+
line.each_char.with_index do |char, col|
|
|
50
|
+
if word_char?(char)
|
|
51
|
+
current_word << char
|
|
52
|
+
else
|
|
53
|
+
check_and_add_word(current_word, word_start, row, col, exclude_row, exclude_col, prefix, prefix_downcase, candidates)
|
|
54
|
+
current_word = +""
|
|
55
|
+
word_start = col + 1
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Handle word at end of line
|
|
60
|
+
check_and_add_word(current_word, word_start, row, line.length, exclude_row, exclude_col, prefix, prefix_downcase, candidates)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def check_and_add_word(word, word_start, row, col, exclude_row, exclude_col, prefix, prefix_downcase, candidates)
|
|
64
|
+
return if word.length < MIN_WORD_LENGTH
|
|
65
|
+
return if word == prefix
|
|
66
|
+
return unless word.downcase.start_with?(prefix_downcase)
|
|
67
|
+
|
|
68
|
+
# Don't include the word at cursor position
|
|
69
|
+
at_cursor = row == exclude_row && word_start <= exclude_col && col > exclude_col
|
|
70
|
+
candidates << word unless at_cursor
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def word_char?(char)
|
|
74
|
+
char&.match?(/\w/)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mui
|
|
4
|
+
class ColorManager
|
|
5
|
+
# Standard 8 colors
|
|
6
|
+
COLOR_MAP = {
|
|
7
|
+
black: 0,
|
|
8
|
+
red: 1,
|
|
9
|
+
green: 2,
|
|
10
|
+
yellow: 3,
|
|
11
|
+
blue: 4,
|
|
12
|
+
magenta: 5,
|
|
13
|
+
cyan: 6,
|
|
14
|
+
white: 7
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
# 256-color palette extended colors
|
|
18
|
+
# Use https://www.ditig.com/256-colors-cheat-sheet for reference
|
|
19
|
+
EXTENDED_COLOR_MAP = {
|
|
20
|
+
# mui theme
|
|
21
|
+
darkgray: 235, # #262626 (~#2b2b2b)
|
|
22
|
+
|
|
23
|
+
# solarized
|
|
24
|
+
solarized_base03: 234, # #1c1c1c (~#002b36)
|
|
25
|
+
solarized_base02: 235, # #262626 (~#073642)
|
|
26
|
+
solarized_base01: 240, # #585858 (~#586e75)
|
|
27
|
+
solarized_base00: 241, # #626262 (~#657b83)
|
|
28
|
+
solarized_base0: 244, # #808080 (~#839496)
|
|
29
|
+
solarized_base1: 245, # #8a8a8a (~#93a1a1)
|
|
30
|
+
solarized_base2: 254, # #e4e4e4 (~#eee8d5)
|
|
31
|
+
solarized_base3: 230, # #ffffd7 (~#fdf6e3)
|
|
32
|
+
solarized_yellow: 136, # #af8700 (~#b58900)
|
|
33
|
+
solarized_orange: 166, # #d75f00 (~#cb4b16)
|
|
34
|
+
solarized_red: 160, # #d70000 (~#dc322f)
|
|
35
|
+
solarized_magenta: 125, # #af005f (~#d33682)
|
|
36
|
+
solarized_violet: 61, # #5f5faf (~#6c71c4)
|
|
37
|
+
solarized_blue: 33, # #0087ff (~#268bd2)
|
|
38
|
+
solarized_cyan: 37, # #00afaf (~#2aa198)
|
|
39
|
+
solarized_green: 64, # #5f8700 (~#859900)
|
|
40
|
+
|
|
41
|
+
# monokai
|
|
42
|
+
monokai_bg: 235, # #262626 (~#272822)
|
|
43
|
+
monokai_fg: 231, # #ffffff (~#f8f8f2)
|
|
44
|
+
monokai_pink: 197, # #ff005f (~#f92672)
|
|
45
|
+
monokai_green: 148, # #afd700 (~#a6e22e)
|
|
46
|
+
monokai_orange: 208, # #ff8700 (~#fd971f)
|
|
47
|
+
monokai_purple: 141, # #af87ff (~#ae81ff)
|
|
48
|
+
monokai_cyan: 81, # #5fd7ff (~#66d9ef)
|
|
49
|
+
monokai_yellow: 186, # #d7d787 (~#e6db74)
|
|
50
|
+
|
|
51
|
+
# nord
|
|
52
|
+
nord_polar0: 236, # #303030 (~#2e3440)
|
|
53
|
+
nord_polar1: 238, # #444444 (~#3b4252)
|
|
54
|
+
nord_polar2: 239, # #4e4e4e (~#434c5e)
|
|
55
|
+
nord_polar3: 240, # #585858 (~#4c566a)
|
|
56
|
+
nord_snow0: 253, # #dadada (~#d8dee9)
|
|
57
|
+
nord_snow1: 254, # #e4e4e4 (~#e5e9f0)
|
|
58
|
+
nord_snow2: 255, # #eeeeee (~#eceff4)
|
|
59
|
+
nord_frost0: 109, # #87afaf (~#8fbcbb)
|
|
60
|
+
nord_frost1: 110, # #87afd7 (~#88c0d0)
|
|
61
|
+
nord_frost2: 111, # #87afff (~#81a1c1)
|
|
62
|
+
nord_frost3: 68, # #5f87d7 (~#5e81ac)
|
|
63
|
+
nord_aurora_red: 167, # #d75f5f (~#bf616a)
|
|
64
|
+
nord_aurora_orange: 208, # #ff8700 (~#d08770)
|
|
65
|
+
nord_aurora_yellow: 179, # #d7af5f (~#ebcb8b)
|
|
66
|
+
nord_aurora_green: 108, # #87af87 (~#a3be8c)
|
|
67
|
+
nord_aurora_purple: 139, # #af87af (~#b48ead)
|
|
68
|
+
|
|
69
|
+
# gruvbox
|
|
70
|
+
gruvbox_bg: 235, # #262626 (~#282828)
|
|
71
|
+
gruvbox_fg: 223, # #ffd7af (~#ebdbb2)
|
|
72
|
+
gruvbox_red: 124, # #af0000 (~#cc241d)
|
|
73
|
+
gruvbox_green: 106, # #87af00 (~#98971a)
|
|
74
|
+
gruvbox_yellow: 172, # #d78700 (~#d79921)
|
|
75
|
+
gruvbox_blue: 66, # #5f8787 (~#458588)
|
|
76
|
+
gruvbox_purple: 132, # #af5f87 (~#b16286)
|
|
77
|
+
gruvbox_aqua: 72, # #5faf87 (~#689d6a)
|
|
78
|
+
gruvbox_orange: 166, # #d75f00 (~#d65d0e)
|
|
79
|
+
gruvbox_gray: 245, # #8a8a8a (~#928374)
|
|
80
|
+
|
|
81
|
+
# dracula
|
|
82
|
+
dracula_bg: 236, # #303030 (~#282a36)
|
|
83
|
+
dracula_fg: 231, # #ffffff (~#f8f8f2)
|
|
84
|
+
dracula_selection: 239, # #4e4e4e (~#44475a)
|
|
85
|
+
dracula_comment: 61, # #5f5faf (~#6272a4)
|
|
86
|
+
dracula_cyan: 117, # #87d7ff (~#8be9fd)
|
|
87
|
+
dracula_green: 84, # #5fdf5f (~#50fa7b)
|
|
88
|
+
dracula_orange: 215, # #ffaf5f (~#ffb86c)
|
|
89
|
+
dracula_pink: 212, # #ff87d7 (~#ff79c6)
|
|
90
|
+
dracula_purple: 141, # #af87ff (~#bd93f9)
|
|
91
|
+
dracula_red: 203, # #ff5f5f (~#ff5555)
|
|
92
|
+
dracula_yellow: 228, # #ffff87 (~#f1fa8c)
|
|
93
|
+
|
|
94
|
+
# tokyo night
|
|
95
|
+
tokyo_bg: 234, # #1c1c1c (~#1a1b26)
|
|
96
|
+
tokyo_fg: 146, # #afafd7 (~#a9b1d6)
|
|
97
|
+
tokyo_comment: 60, # #5f5f87 (~#565f89)
|
|
98
|
+
tokyo_cyan: 115, # #87d7af (~#7dcfff)
|
|
99
|
+
tokyo_blue: 75, # #5fafff (~#7aa2f7)
|
|
100
|
+
tokyo_purple: 140, # #af87d7 (~#bb9af7)
|
|
101
|
+
tokyo_green: 108, # #87af87 (~#9ece6a)
|
|
102
|
+
tokyo_orange: 215, # #ffaf5f (~#ff9e64)
|
|
103
|
+
tokyo_red: 203, # #ff5f5f (~#f7768e)
|
|
104
|
+
tokyo_yellow: 223 # #ffd7af (~#e0af68)
|
|
105
|
+
}.freeze
|
|
106
|
+
|
|
107
|
+
attr_reader :pairs
|
|
108
|
+
|
|
109
|
+
def initialize
|
|
110
|
+
@pair_index = 1
|
|
111
|
+
@pairs = {}
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def register_pair(fg, bg)
|
|
115
|
+
key = [fg, bg]
|
|
116
|
+
return @pairs[key] if @pairs[key]
|
|
117
|
+
|
|
118
|
+
@pairs[key] = @pair_index
|
|
119
|
+
@pair_index += 1
|
|
120
|
+
@pairs[key]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def get_pair_index(fg, bg)
|
|
124
|
+
register_pair(fg, bg)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def color_code(color)
|
|
128
|
+
return -1 if color.nil?
|
|
129
|
+
return color if color.is_a?(Integer)
|
|
130
|
+
|
|
131
|
+
COLOR_MAP[color] || EXTENDED_COLOR_MAP[color] || -1
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
alias resolve color_code
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mui
|
|
4
|
+
class ColorScheme
|
|
5
|
+
ELEMENTS = %i[
|
|
6
|
+
normal
|
|
7
|
+
status_line
|
|
8
|
+
status_line_mode
|
|
9
|
+
search_highlight
|
|
10
|
+
visual_selection
|
|
11
|
+
line_number
|
|
12
|
+
message_error
|
|
13
|
+
message_info
|
|
14
|
+
tab_bar
|
|
15
|
+
tab_bar_active
|
|
16
|
+
completion_popup
|
|
17
|
+
completion_popup_selected
|
|
18
|
+
syntax_keyword
|
|
19
|
+
syntax_string
|
|
20
|
+
syntax_comment
|
|
21
|
+
syntax_number
|
|
22
|
+
syntax_symbol
|
|
23
|
+
syntax_constant
|
|
24
|
+
syntax_operator
|
|
25
|
+
syntax_identifier
|
|
26
|
+
syntax_preprocessor
|
|
27
|
+
syntax_instance_variable
|
|
28
|
+
syntax_global_variable
|
|
29
|
+
syntax_method_call
|
|
30
|
+
syntax_type
|
|
31
|
+
diff_add
|
|
32
|
+
diff_delete
|
|
33
|
+
diff_hunk
|
|
34
|
+
diff_header
|
|
35
|
+
].freeze
|
|
36
|
+
|
|
37
|
+
attr_reader :name, :colors
|
|
38
|
+
|
|
39
|
+
def initialize(name)
|
|
40
|
+
@name = name
|
|
41
|
+
@colors = {}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def define(element, fg:, bg: nil, bold: false, underline: false)
|
|
45
|
+
@colors[element] = {
|
|
46
|
+
fg:,
|
|
47
|
+
bg:,
|
|
48
|
+
bold:,
|
|
49
|
+
underline:
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def [](element)
|
|
54
|
+
@colors[element] || default_color
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def default_color
|
|
60
|
+
{ fg: :white, bg: nil, bold: false, underline: false }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mui
|
|
4
|
+
# Provides command name completion
|
|
5
|
+
class CommandCompleter
|
|
6
|
+
COMMANDS = %w[
|
|
7
|
+
e w q wq q!
|
|
8
|
+
sp split vs vsplit close only
|
|
9
|
+
tabnew tabe tabedit tabclose tabc
|
|
10
|
+
tabnext tabn tabprev tabp tabprevious
|
|
11
|
+
tabfirst tabf tabrewind tabr tablast tabl
|
|
12
|
+
tabmove tabm
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
def complete(prefix)
|
|
16
|
+
all_commands = COMMANDS + plugin_command_names
|
|
17
|
+
|
|
18
|
+
return all_commands.uniq.sort if prefix.empty?
|
|
19
|
+
|
|
20
|
+
prefix_downcase = prefix.downcase
|
|
21
|
+
all_commands.select { |cmd| cmd.downcase.start_with?(prefix_downcase) }.uniq.sort
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def plugin_command_names
|
|
27
|
+
Mui.config.commands.keys.map(&:to_s)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|