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
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mui
|
|
4
|
+
# Provides plugin access to editor internals
|
|
5
|
+
class CommandContext
|
|
6
|
+
attr_reader :buffer, :window, :editor
|
|
7
|
+
|
|
8
|
+
def initialize(editor:, buffer:, window:)
|
|
9
|
+
@editor = editor
|
|
10
|
+
@buffer = buffer
|
|
11
|
+
@window = window
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def cursor
|
|
15
|
+
{ line: @buffer.cursor_y, col: @buffer.cursor_x }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def current_line
|
|
19
|
+
@buffer.current_line
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def insert(text)
|
|
23
|
+
@buffer.insert_text(text)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def set_message(msg)
|
|
27
|
+
@editor.message = msg
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def quit
|
|
31
|
+
@editor.running = false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def run_command(name, *)
|
|
35
|
+
@editor.command_registry.execute(name, self, *)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def run_async(on_complete: nil, &)
|
|
39
|
+
@editor.job_manager.run_async(on_complete:, &)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def run_shell_command(cmd, on_complete: nil)
|
|
43
|
+
@editor.job_manager.run_command(cmd, on_complete:)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def jobs_running?
|
|
47
|
+
@editor.job_manager.busy?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def cancel_job(id)
|
|
51
|
+
@editor.job_manager.cancel(id)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def open_scratch_buffer(name, content)
|
|
55
|
+
@editor.open_scratch_buffer(name, content)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Run an interactive command that needs terminal access (e.g., fzf)
|
|
59
|
+
# Suspends Curses UI, runs command, resumes UI
|
|
60
|
+
def run_interactive_command(cmd)
|
|
61
|
+
require "tempfile"
|
|
62
|
+
|
|
63
|
+
@editor.suspend_ui do
|
|
64
|
+
output_file = Tempfile.new("mui_interactive")
|
|
65
|
+
begin
|
|
66
|
+
# Use shell redirection to capture output while keeping stdin/stderr connected to terminal
|
|
67
|
+
# rubocop:disable Style/SpecialGlobalVars
|
|
68
|
+
success = system("#{cmd} > #{output_file.path}")
|
|
69
|
+
status = $?
|
|
70
|
+
# rubocop:enable Style/SpecialGlobalVars
|
|
71
|
+
exit_status = status&.exitstatus || 1
|
|
72
|
+
{
|
|
73
|
+
stdout: File.read(output_file.path),
|
|
74
|
+
stderr: "",
|
|
75
|
+
exit_status:,
|
|
76
|
+
success: success == true && exit_status.zero?
|
|
77
|
+
}
|
|
78
|
+
ensure
|
|
79
|
+
output_file.close
|
|
80
|
+
output_file.unlink
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Check if a command exists in PATH
|
|
86
|
+
def command_exists?(cmd)
|
|
87
|
+
system("which #{cmd} > /dev/null 2>&1")
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -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
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mui
|
|
4
|
+
class CommandLine
|
|
5
|
+
# Commands that accept file path arguments
|
|
6
|
+
FILE_COMMANDS = %w[e w sp split vs vsplit tabnew tabe tabedit].freeze
|
|
7
|
+
|
|
8
|
+
attr_reader :buffer, :cursor_pos, :history
|
|
9
|
+
|
|
10
|
+
def initialize(history: CommandHistory.new)
|
|
11
|
+
@buffer = ""
|
|
12
|
+
@cursor_pos = 0
|
|
13
|
+
@history = history
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def input(char)
|
|
17
|
+
@buffer = @buffer[0...@cursor_pos].to_s + char + @buffer[@cursor_pos..].to_s
|
|
18
|
+
@cursor_pos += char.length
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def backspace
|
|
22
|
+
return if @cursor_pos.zero?
|
|
23
|
+
|
|
24
|
+
@buffer = @buffer[0...(@cursor_pos - 1)].to_s + @buffer[@cursor_pos..].to_s
|
|
25
|
+
@cursor_pos -= 1
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def clear
|
|
29
|
+
@buffer = ""
|
|
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
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def move_cursor_left
|
|
53
|
+
@cursor_pos -= 1 if @cursor_pos.positive?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def move_cursor_right
|
|
57
|
+
@cursor_pos += 1 if @cursor_pos < @buffer.length
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def execute
|
|
61
|
+
command = @buffer.strip
|
|
62
|
+
@history.add(command) unless command.empty?
|
|
63
|
+
@history.reset
|
|
64
|
+
result = parse(@buffer)
|
|
65
|
+
@buffer = ""
|
|
66
|
+
@cursor_pos = 0
|
|
67
|
+
result
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def to_s
|
|
71
|
+
":#{@buffer}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Determine completion context based on current buffer
|
|
75
|
+
def completion_context
|
|
76
|
+
# Check if buffer contains a space (command + argument)
|
|
77
|
+
if @buffer.include?(" ")
|
|
78
|
+
# Command with argument
|
|
79
|
+
parts = @buffer.split(/\s+/, 2)
|
|
80
|
+
command = parts[0]
|
|
81
|
+
arg = parts[1] || ""
|
|
82
|
+
|
|
83
|
+
return { type: :file, command:, prefix: arg } if FILE_COMMANDS.include?(command)
|
|
84
|
+
|
|
85
|
+
# Return nil for commands that don't support file completion
|
|
86
|
+
return nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# No space -> command completion
|
|
90
|
+
{ type: :command, prefix: @buffer.strip }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Apply completion result to buffer
|
|
94
|
+
def apply_completion(text, context)
|
|
95
|
+
@buffer = if context[:type] == :command
|
|
96
|
+
text
|
|
97
|
+
else
|
|
98
|
+
"#{context[:command]} #{text}"
|
|
99
|
+
end
|
|
100
|
+
@cursor_pos = @buffer.length
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def parse(cmd)
|
|
106
|
+
case cmd.strip
|
|
107
|
+
when ""
|
|
108
|
+
{ action: :no_op }
|
|
109
|
+
when "e"
|
|
110
|
+
{ action: :open }
|
|
111
|
+
when /^e\s+(.+)/
|
|
112
|
+
{ action: :open_as, path: ::Regexp.last_match(1) }
|
|
113
|
+
when "w"
|
|
114
|
+
{ action: :write }
|
|
115
|
+
when "q"
|
|
116
|
+
{ action: :quit }
|
|
117
|
+
when "wq"
|
|
118
|
+
{ action: :write_quit }
|
|
119
|
+
when "q!"
|
|
120
|
+
{ action: :force_quit }
|
|
121
|
+
when /^w\s+(.+)/
|
|
122
|
+
{ action: :write_as, path: ::Regexp.last_match(1) }
|
|
123
|
+
when "sp", "split"
|
|
124
|
+
{ action: :split_horizontal }
|
|
125
|
+
when /^sp\s+(.+)/, /^split\s+(.+)/
|
|
126
|
+
{ action: :split_horizontal, path: ::Regexp.last_match(1) }
|
|
127
|
+
when "vs", "vsplit"
|
|
128
|
+
{ action: :split_vertical }
|
|
129
|
+
when /^vs\s+(.+)/, /^vsplit\s+(.+)/
|
|
130
|
+
{ action: :split_vertical, path: ::Regexp.last_match(1) }
|
|
131
|
+
when "close"
|
|
132
|
+
{ action: :close_window }
|
|
133
|
+
when "only"
|
|
134
|
+
{ action: :only_window }
|
|
135
|
+
when "tabnew", "tabe", "tabedit"
|
|
136
|
+
{ action: :tab_new }
|
|
137
|
+
when /^tabnew\s+(.+)/, /^tabe\s+(.+)/, /^tabedit\s+(.+)/
|
|
138
|
+
{ action: :tab_new, path: ::Regexp.last_match(1) }
|
|
139
|
+
when "tabclose", "tabc"
|
|
140
|
+
{ action: :tab_close }
|
|
141
|
+
when "tabnext", "tabn"
|
|
142
|
+
{ action: :tab_next }
|
|
143
|
+
when "tabprev", "tabp", "tabprevious"
|
|
144
|
+
{ action: :tab_prev }
|
|
145
|
+
when "tabfirst", "tabf", "tabrewind", "tabr"
|
|
146
|
+
{ action: :tab_first }
|
|
147
|
+
when "tablast", "tabl"
|
|
148
|
+
{ action: :tab_last }
|
|
149
|
+
when /^tabmove\s+(\d+)/, /^tabm\s+(\d+)/
|
|
150
|
+
{ action: :tab_move, position: ::Regexp.last_match(1).to_i }
|
|
151
|
+
when /^(\d+)tabn(?:ext)?/, /^tabn(?:ext)?\s+(\d+)/
|
|
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
|
|
160
|
+
when /^(\d+)$/
|
|
161
|
+
{ action: :goto_line, line_number: ::Regexp.last_match(1).to_i }
|
|
162
|
+
else
|
|
163
|
+
{ action: :unknown, command: cmd }
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mui
|
|
4
|
+
# Registry for Ex commands
|
|
5
|
+
class CommandRegistry
|
|
6
|
+
def initialize
|
|
7
|
+
@commands = {}
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def register(name, &block)
|
|
11
|
+
@commands[name.to_sym] = block
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def execute(name, context, *)
|
|
15
|
+
command = find(name)
|
|
16
|
+
raise UnknownCommandError, name unless command
|
|
17
|
+
|
|
18
|
+
command.call(context, *)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def exists?(name)
|
|
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
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mui
|
|
4
|
+
# Renders completion popup menu
|
|
5
|
+
class CompletionRenderer
|
|
6
|
+
MAX_VISIBLE_ITEMS = 10
|
|
7
|
+
|
|
8
|
+
def initialize(screen, color_scheme)
|
|
9
|
+
@screen = screen
|
|
10
|
+
@color_scheme = color_scheme
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def render(completion_state, base_row, base_col)
|
|
14
|
+
return unless completion_state.active?
|
|
15
|
+
|
|
16
|
+
candidates = completion_state.candidates
|
|
17
|
+
selected_index = completion_state.selected_index
|
|
18
|
+
|
|
19
|
+
# Calculate visible window
|
|
20
|
+
visible_start, visible_end = calculate_visible_range(candidates.length, selected_index)
|
|
21
|
+
visible_candidates = candidates[visible_start...visible_end]
|
|
22
|
+
|
|
23
|
+
# Calculate popup dimensions
|
|
24
|
+
max_width = calculate_max_width(visible_candidates)
|
|
25
|
+
popup_height = visible_candidates.length
|
|
26
|
+
|
|
27
|
+
# Calculate position (popup appears above the command line)
|
|
28
|
+
popup_row = base_row - popup_height
|
|
29
|
+
popup_col = base_col
|
|
30
|
+
|
|
31
|
+
# Ensure popup stays within screen bounds
|
|
32
|
+
popup_col = [@screen.width - max_width - 1, popup_col].min
|
|
33
|
+
popup_col = [0, popup_col].max
|
|
34
|
+
popup_row = [0, popup_row].max
|
|
35
|
+
|
|
36
|
+
# Render each visible candidate
|
|
37
|
+
visible_candidates.each_with_index do |candidate, i|
|
|
38
|
+
actual_index = visible_start + i
|
|
39
|
+
is_selected = actual_index == selected_index
|
|
40
|
+
|
|
41
|
+
render_item(
|
|
42
|
+
candidate,
|
|
43
|
+
popup_row + i,
|
|
44
|
+
popup_col,
|
|
45
|
+
max_width,
|
|
46
|
+
is_selected
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def calculate_visible_range(total_count, selected_index)
|
|
54
|
+
return [0, total_count] if total_count <= MAX_VISIBLE_ITEMS
|
|
55
|
+
|
|
56
|
+
# Try to center the selected item
|
|
57
|
+
half = MAX_VISIBLE_ITEMS / 2
|
|
58
|
+
start_index = selected_index - half
|
|
59
|
+
start_index = [0, start_index].max
|
|
60
|
+
end_index = start_index + MAX_VISIBLE_ITEMS
|
|
61
|
+
end_index = [total_count, end_index].min
|
|
62
|
+
start_index = end_index - MAX_VISIBLE_ITEMS
|
|
63
|
+
|
|
64
|
+
[start_index, end_index]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def calculate_max_width(candidates)
|
|
68
|
+
return 0 if candidates.empty?
|
|
69
|
+
|
|
70
|
+
candidates.map { |c| display_width(c) }.max + 2 # +2 for padding
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def display_width(text)
|
|
74
|
+
UnicodeWidth.string_width(text)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def render_item(text, row, col, width, selected)
|
|
78
|
+
style_key = selected ? :completion_popup_selected : :completion_popup
|
|
79
|
+
style = @color_scheme[style_key]
|
|
80
|
+
padded_text = " #{text}".ljust(width)
|
|
81
|
+
@screen.put_with_style(row, col, padded_text, style)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mui
|
|
4
|
+
# Manages completion popup state
|
|
5
|
+
class CompletionState
|
|
6
|
+
attr_reader :candidates, :selected_index, :original_input, :completion_type
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
reset
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def reset
|
|
13
|
+
@candidates = []
|
|
14
|
+
@selected_index = 0
|
|
15
|
+
@original_input = nil
|
|
16
|
+
@completion_type = nil # :command or :file
|
|
17
|
+
@confirmed = false
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def active?
|
|
21
|
+
!@candidates.empty?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def start(candidates, original_input, type)
|
|
25
|
+
@candidates = candidates
|
|
26
|
+
@selected_index = 0
|
|
27
|
+
@original_input = original_input
|
|
28
|
+
@completion_type = type
|
|
29
|
+
@confirmed = false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def confirm
|
|
33
|
+
@confirmed = true
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def confirmed?
|
|
37
|
+
@confirmed
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def select_next
|
|
41
|
+
return unless active?
|
|
42
|
+
|
|
43
|
+
@selected_index = (@selected_index + 1) % @candidates.length
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def select_previous
|
|
47
|
+
return unless active?
|
|
48
|
+
|
|
49
|
+
@selected_index = (@selected_index - 1) % @candidates.length
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def current_candidate
|
|
53
|
+
return nil unless active?
|
|
54
|
+
|
|
55
|
+
@candidates[@selected_index]
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
data/lib/mui/config.rb
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mui
|
|
4
|
+
class Config
|
|
5
|
+
attr_reader :options, :plugins, :keymaps, :commands, :autocmds
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@options = {
|
|
9
|
+
colorscheme: "mui",
|
|
10
|
+
syntax: true, # Enable/disable syntax highlighting
|
|
11
|
+
shiftwidth: 2, # Indent width for > and < commands
|
|
12
|
+
expandtab: true, # Use spaces instead of tabs
|
|
13
|
+
tabstop: 8, # Tab display width
|
|
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
|
|
17
|
+
}
|
|
18
|
+
@plugins = []
|
|
19
|
+
@keymaps = {}
|
|
20
|
+
@commands = {}
|
|
21
|
+
@autocmds = {}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def set(key, value)
|
|
25
|
+
@options[key.to_sym] = value
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def get(key)
|
|
29
|
+
@options[key.to_sym]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def load_file(path)
|
|
33
|
+
return unless File.exist?(path)
|
|
34
|
+
|
|
35
|
+
instance_eval(File.read(path), path)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Stub for future plugin support
|
|
39
|
+
def use_plugin(gem_name, version = nil)
|
|
40
|
+
@plugins << { gem: gem_name, version: }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Stub for future keymap support
|
|
44
|
+
def add_keymap(mode, key, block)
|
|
45
|
+
@keymaps[mode] ||= {}
|
|
46
|
+
@keymaps[mode][key] = block
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def add_command(name, block)
|
|
50
|
+
@commands[name.to_sym] = block
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def add_autocmd(event, pattern, block)
|
|
54
|
+
@autocmds[event.to_sym] ||= []
|
|
55
|
+
@autocmds[event.to_sym] << { pattern:, handler: block }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|