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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +158 -0
- data/CHANGELOG.md +349 -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 +21 -0
- data/lib/mui/command_context.rb +90 -0
- data/lib/mui/command_line.rb +137 -0
- data/lib/mui/command_registry.rb +25 -0
- data/lib/mui/completion_renderer.rb +84 -0
- data/lib/mui/completion_state.rb +58 -0
- data/lib/mui/config.rb +56 -0
- data/lib/mui/editor.rb +319 -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 +101 -0
- data/lib/mui/highlight.rb +22 -0
- data/lib/mui/highlighters/base.rb +23 -0
- data/lib/mui/highlighters/search_highlighter.rb +26 -0
- data/lib/mui/highlighters/selection_highlighter.rb +48 -0
- data/lib/mui/highlighters/syntax_highlighter.rb +105 -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 +100 -0
- data/lib/mui/key_handler/command_mode.rb +443 -0
- data/lib/mui/key_handler/insert_mode.rb +354 -0
- data/lib/mui/key_handler/motions/motion_handler.rb +56 -0
- data/lib/mui/key_handler/normal_mode.rb +579 -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 +113 -0
- data/lib/mui/key_handler/operators/yank_operator.rb +127 -0
- data/lib/mui/key_handler/search_mode.rb +188 -0
- data/lib/mui/key_handler/visual_line_mode.rb +20 -0
- data/lib/mui/key_handler/visual_mode.rb +397 -0
- data/lib/mui/key_handler/window_command.rb +112 -0
- data/lib/mui/key_handler.rb +16 -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 +122 -0
- data/lib/mui/mode.rb +13 -0
- data/lib/mui/mode_manager.rb +185 -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 +85 -0
- data/lib/mui/search_completer.rb +50 -0
- data/lib/mui/search_input.rb +40 -0
- data/lib/mui/search_state.rb +88 -0
- data/lib/mui/selection.rb +55 -0
- data/lib/mui/status_line_renderer.rb +40 -0
- data/lib/mui/syntax/language_detector.rb +74 -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/ruby_lexer.rb +114 -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 +162 -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 +158 -0
- data/lib/mui/window_manager.rb +249 -0
- data/lib/mui.rb +156 -2
- metadata +98 -3
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mui
|
|
4
|
+
module Highlighters
|
|
5
|
+
class SearchHighlighter < Base
|
|
6
|
+
def highlights_for(row, _line, options = {})
|
|
7
|
+
search_state = options[:search_state]
|
|
8
|
+
return [] unless search_state&.has_pattern?
|
|
9
|
+
|
|
10
|
+
matches = search_state.matches_for_row(row)
|
|
11
|
+
matches.map do |match|
|
|
12
|
+
Highlight.new(
|
|
13
|
+
start_col: match[:col],
|
|
14
|
+
end_col: match[:end_col],
|
|
15
|
+
style: :search_highlight,
|
|
16
|
+
priority:
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def priority
|
|
22
|
+
PRIORITY_SEARCH
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mui
|
|
4
|
+
module Highlighters
|
|
5
|
+
class SelectionHighlighter < Base
|
|
6
|
+
def highlights_for(row, line, options = {})
|
|
7
|
+
selection = options[:selection]
|
|
8
|
+
return [] unless selection
|
|
9
|
+
|
|
10
|
+
range = selection.normalized_range
|
|
11
|
+
return [] if row < range[:start_row] || row > range[:end_row]
|
|
12
|
+
|
|
13
|
+
if selection.line_mode
|
|
14
|
+
line_mode_highlights(line)
|
|
15
|
+
else
|
|
16
|
+
char_mode_highlights(row, line, range)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def priority
|
|
21
|
+
PRIORITY_SELECTION
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def line_mode_highlights(line)
|
|
27
|
+
[Highlight.new(
|
|
28
|
+
start_col: 0,
|
|
29
|
+
end_col: [line.length - 1, 0].max,
|
|
30
|
+
style: :visual_selection,
|
|
31
|
+
priority:
|
|
32
|
+
)]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def char_mode_highlights(row, line, range)
|
|
36
|
+
start_col = row == range[:start_row] ? range[:start_col] : 0
|
|
37
|
+
end_col = row == range[:end_row] ? range[:end_col] : [line.length - 1, 0].max
|
|
38
|
+
|
|
39
|
+
[Highlight.new(
|
|
40
|
+
start_col:,
|
|
41
|
+
end_col:,
|
|
42
|
+
style: :visual_selection,
|
|
43
|
+
priority:
|
|
44
|
+
)]
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mui
|
|
4
|
+
module Highlighters
|
|
5
|
+
# Provides syntax highlighting based on language-specific lexers
|
|
6
|
+
class SyntaxHighlighter < Base
|
|
7
|
+
# Maps token types to ColorScheme style names
|
|
8
|
+
# Note: identifier and operator are excluded to reduce highlight count
|
|
9
|
+
# (they typically use the same color as normal text)
|
|
10
|
+
TOKEN_STYLE_MAP = {
|
|
11
|
+
keyword: :syntax_keyword,
|
|
12
|
+
string: :syntax_string,
|
|
13
|
+
comment: :syntax_comment,
|
|
14
|
+
number: :syntax_number,
|
|
15
|
+
symbol: :syntax_symbol,
|
|
16
|
+
constant: :syntax_constant,
|
|
17
|
+
preprocessor: :syntax_preprocessor,
|
|
18
|
+
char: :syntax_string,
|
|
19
|
+
instance_variable: :syntax_instance_variable,
|
|
20
|
+
global_variable: :syntax_global_variable,
|
|
21
|
+
method_call: :syntax_method_call,
|
|
22
|
+
type: :syntax_type
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
def initialize(color_scheme, buffer: nil)
|
|
26
|
+
super(color_scheme)
|
|
27
|
+
@buffer = buffer
|
|
28
|
+
setup_lexer
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Update the buffer and reset the lexer
|
|
32
|
+
def buffer=(new_buffer)
|
|
33
|
+
@buffer = new_buffer
|
|
34
|
+
setup_lexer
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Generate highlights for a line
|
|
38
|
+
# TODO: Refactor to reduce complexity (Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity)
|
|
39
|
+
def highlights_for(row, line, options = {})
|
|
40
|
+
return [] unless @lexer
|
|
41
|
+
return [] unless Mui.config.get(:syntax)
|
|
42
|
+
|
|
43
|
+
buffer_lines = options[:buffer]&.lines || @buffer&.lines || []
|
|
44
|
+
tokens = @token_cache.tokens_for(row, line, buffer_lines)
|
|
45
|
+
|
|
46
|
+
tokens.filter_map do |token|
|
|
47
|
+
style = style_for_token_type(token.type)
|
|
48
|
+
next unless style && @color_scheme[style]
|
|
49
|
+
|
|
50
|
+
Highlight.new(
|
|
51
|
+
start_col: token.start_col,
|
|
52
|
+
end_col: token.end_col,
|
|
53
|
+
style:,
|
|
54
|
+
priority:
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def priority
|
|
60
|
+
PRIORITY_SYNTAX
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Invalidate cache from a specific row onwards
|
|
64
|
+
# Called when buffer content changes
|
|
65
|
+
def invalidate_from(row)
|
|
66
|
+
@token_cache&.invalidate(row)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Clear the entire cache
|
|
70
|
+
def clear_cache
|
|
71
|
+
@token_cache&.clear
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Prefetch tokens for lines around the visible area
|
|
75
|
+
def prefetch(visible_start, visible_end)
|
|
76
|
+
return unless @lexer && @token_cache && @buffer
|
|
77
|
+
return unless Mui.config.get(:syntax)
|
|
78
|
+
|
|
79
|
+
@token_cache.prefetch(visible_start, visible_end, @buffer.lines)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Check if this highlighter is active (has a lexer)
|
|
83
|
+
def active?
|
|
84
|
+
!@lexer.nil?
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def setup_lexer
|
|
90
|
+
@lexer = nil
|
|
91
|
+
@token_cache = nil
|
|
92
|
+
return unless @buffer
|
|
93
|
+
|
|
94
|
+
@lexer = Syntax::LanguageDetector.lexer_for_file(@buffer.name)
|
|
95
|
+
return unless @lexer
|
|
96
|
+
|
|
97
|
+
@token_cache = Syntax::TokenCache.new(@lexer)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def style_for_token_type(type)
|
|
101
|
+
TOKEN_STYLE_MAP[type]
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
data/lib/mui/input.rb
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mui
|
|
4
|
+
# Renders completion popup for Insert mode (LSP completions)
|
|
5
|
+
class InsertCompletionRenderer
|
|
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, cursor_row, cursor_col)
|
|
14
|
+
return unless completion_state.active?
|
|
15
|
+
|
|
16
|
+
items = completion_state.items
|
|
17
|
+
selected_index = completion_state.selected_index
|
|
18
|
+
|
|
19
|
+
# Calculate visible window
|
|
20
|
+
visible_start, visible_end = calculate_visible_range(items.length, selected_index)
|
|
21
|
+
visible_items = items[visible_start...visible_end]
|
|
22
|
+
|
|
23
|
+
# Calculate popup dimensions
|
|
24
|
+
max_width = calculate_max_width(visible_items)
|
|
25
|
+
popup_height = visible_items.length
|
|
26
|
+
|
|
27
|
+
# Calculate position (popup appears below the cursor)
|
|
28
|
+
popup_row = cursor_row + 1
|
|
29
|
+
popup_col = cursor_col
|
|
30
|
+
|
|
31
|
+
# If popup would go below screen, show above cursor instead
|
|
32
|
+
popup_row = cursor_row - popup_height if popup_row + popup_height > @screen.height - 1
|
|
33
|
+
|
|
34
|
+
# Ensure popup stays within screen bounds
|
|
35
|
+
popup_col = [@screen.width - max_width - 1, popup_col].min
|
|
36
|
+
popup_col = [0, popup_col].max
|
|
37
|
+
popup_row = [0, popup_row].max
|
|
38
|
+
|
|
39
|
+
# Render each visible item
|
|
40
|
+
visible_items.each_with_index do |item, i|
|
|
41
|
+
actual_index = visible_start + i
|
|
42
|
+
is_selected = actual_index == selected_index
|
|
43
|
+
|
|
44
|
+
render_item(
|
|
45
|
+
item,
|
|
46
|
+
popup_row + i,
|
|
47
|
+
popup_col,
|
|
48
|
+
max_width,
|
|
49
|
+
is_selected
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def calculate_visible_range(total_count, selected_index)
|
|
57
|
+
return [0, total_count] if total_count <= MAX_VISIBLE_ITEMS
|
|
58
|
+
|
|
59
|
+
# Try to center the selected item
|
|
60
|
+
half = MAX_VISIBLE_ITEMS / 2
|
|
61
|
+
start_index = selected_index - half
|
|
62
|
+
start_index = [0, start_index].max
|
|
63
|
+
end_index = start_index + MAX_VISIBLE_ITEMS
|
|
64
|
+
end_index = [total_count, end_index].min
|
|
65
|
+
start_index = end_index - MAX_VISIBLE_ITEMS
|
|
66
|
+
|
|
67
|
+
[start_index, end_index]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def calculate_max_width(items)
|
|
71
|
+
return 0 if items.empty?
|
|
72
|
+
|
|
73
|
+
items.map { |item| display_width(item_label(item)) }.max + 2 # +2 for padding
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def item_label(item)
|
|
77
|
+
item[:label] || item.to_s
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def display_width(text)
|
|
81
|
+
UnicodeWidth.string_width(text)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def render_item(item, row, col, width, selected)
|
|
85
|
+
style_key = selected ? :completion_popup_selected : :completion_popup
|
|
86
|
+
style = @color_scheme[style_key]
|
|
87
|
+
label = item_label(item)
|
|
88
|
+
padded_text = " #{label}".ljust(width)
|
|
89
|
+
@screen.put_with_style(row, col, padded_text, style)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mui
|
|
4
|
+
# Manages Insert mode completion state for LSP completions
|
|
5
|
+
class InsertCompletionState
|
|
6
|
+
attr_reader :items, :selected_index, :prefix, :original_items
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
reset
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def reset
|
|
13
|
+
@items = []
|
|
14
|
+
@original_items = []
|
|
15
|
+
@selected_index = 0
|
|
16
|
+
@prefix = ""
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def active?
|
|
20
|
+
!@items.empty?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def start(items, prefix: "")
|
|
24
|
+
@original_items = items.dup
|
|
25
|
+
@items = items
|
|
26
|
+
@selected_index = 0
|
|
27
|
+
@prefix = prefix
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Update prefix and filter items based on new prefix
|
|
31
|
+
def update_prefix(new_prefix)
|
|
32
|
+
return if new_prefix == @prefix
|
|
33
|
+
|
|
34
|
+
@prefix = new_prefix
|
|
35
|
+
@items = @original_items.select do |item|
|
|
36
|
+
label = item[:label] || item[:insert_text] || ""
|
|
37
|
+
label.downcase.start_with?(new_prefix.downcase)
|
|
38
|
+
end
|
|
39
|
+
@selected_index = 0
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def select_next
|
|
43
|
+
return unless active?
|
|
44
|
+
|
|
45
|
+
@selected_index = (@selected_index + 1) % @items.length
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def select_previous
|
|
49
|
+
return unless active?
|
|
50
|
+
|
|
51
|
+
@selected_index = (@selected_index - 1) % @items.length
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def current_item
|
|
55
|
+
return nil unless active?
|
|
56
|
+
|
|
57
|
+
@items[@selected_index]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Returns the text to insert for the current item
|
|
61
|
+
def insert_text
|
|
62
|
+
return nil unless current_item
|
|
63
|
+
|
|
64
|
+
item = current_item
|
|
65
|
+
# Prefer textEdit.newText if available (text_edit value has string keys from LSP)
|
|
66
|
+
item.dig(:text_edit, "newText") || item[:insert_text] || item[:label] || item.to_s
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Returns the textEdit range if available
|
|
70
|
+
def text_edit_range
|
|
71
|
+
return nil unless current_item
|
|
72
|
+
|
|
73
|
+
# text_edit value has string keys from LSP
|
|
74
|
+
current_item.dig(:text_edit, "range")
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
data/lib/mui/job.rb
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mui
|
|
4
|
+
# Represents a single asynchronous job
|
|
5
|
+
class Job
|
|
6
|
+
# Job status constants
|
|
7
|
+
module Status
|
|
8
|
+
PENDING = :pending
|
|
9
|
+
RUNNING = :running
|
|
10
|
+
COMPLETED = :completed
|
|
11
|
+
FAILED = :failed
|
|
12
|
+
CANCELLED = :cancelled
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
attr_reader :id, :status, :result, :error
|
|
16
|
+
|
|
17
|
+
def initialize(id, &block)
|
|
18
|
+
@id = id
|
|
19
|
+
@block = block
|
|
20
|
+
@status = Status::PENDING
|
|
21
|
+
@result = nil
|
|
22
|
+
@error = nil
|
|
23
|
+
@cancelled = false
|
|
24
|
+
@mutex = Mutex.new
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def run
|
|
28
|
+
@mutex.synchronize do
|
|
29
|
+
return if @cancelled
|
|
30
|
+
|
|
31
|
+
@status = Status::RUNNING
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
begin
|
|
35
|
+
@result = @block.call
|
|
36
|
+
@mutex.synchronize do
|
|
37
|
+
@status = @cancelled ? Status::CANCELLED : Status::COMPLETED
|
|
38
|
+
end
|
|
39
|
+
rescue StandardError => e
|
|
40
|
+
@mutex.synchronize do
|
|
41
|
+
@error = e
|
|
42
|
+
@status = Status::FAILED
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def cancel
|
|
48
|
+
@mutex.synchronize do
|
|
49
|
+
return false if @status == Status::COMPLETED || @status == Status::FAILED
|
|
50
|
+
|
|
51
|
+
@cancelled = true
|
|
52
|
+
@status = Status::CANCELLED if @status == Status::PENDING
|
|
53
|
+
true
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def pending?
|
|
58
|
+
@status == Status::PENDING
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def running?
|
|
62
|
+
@status == Status::RUNNING
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def completed?
|
|
66
|
+
@status == Status::COMPLETED
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def failed?
|
|
70
|
+
@status == Status::FAILED
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def cancelled?
|
|
74
|
+
@status == Status::CANCELLED
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def finished?
|
|
78
|
+
completed? || failed? || cancelled?
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Mui
|
|
6
|
+
# Manages asynchronous job execution and result collection
|
|
7
|
+
class JobManager
|
|
8
|
+
def initialize(autocmd: nil)
|
|
9
|
+
@autocmd = autocmd
|
|
10
|
+
@result_queue = Queue.new
|
|
11
|
+
@next_id = 0
|
|
12
|
+
@id_mutex = Mutex.new
|
|
13
|
+
@active_jobs = {}
|
|
14
|
+
@jobs_mutex = Mutex.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def run_async(on_complete: nil, &)
|
|
18
|
+
job = create_job(&)
|
|
19
|
+
|
|
20
|
+
Thread.new do
|
|
21
|
+
job.run
|
|
22
|
+
@result_queue.push({ job:, callback: on_complete })
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
job
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def run_command(cmd, on_complete: nil)
|
|
29
|
+
run_async(on_complete:) do
|
|
30
|
+
stdout, stderr, status = Open3.capture3(*Array(cmd))
|
|
31
|
+
{
|
|
32
|
+
stdout:,
|
|
33
|
+
stderr:,
|
|
34
|
+
exit_status: status.exitstatus,
|
|
35
|
+
success: status.success?
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def poll
|
|
41
|
+
processed = []
|
|
42
|
+
|
|
43
|
+
loop do
|
|
44
|
+
entry = @result_queue.pop(true) # non-blocking
|
|
45
|
+
processed << entry
|
|
46
|
+
invoke_callback(entry)
|
|
47
|
+
remove_job(entry[:job].id)
|
|
48
|
+
rescue ThreadError
|
|
49
|
+
break # Queue is empty
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
processed
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def job(id)
|
|
56
|
+
@jobs_mutex.synchronize { @active_jobs[id] }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def cancel(id)
|
|
60
|
+
job = job(id)
|
|
61
|
+
return false unless job
|
|
62
|
+
|
|
63
|
+
job.cancel
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def active_count
|
|
67
|
+
@jobs_mutex.synchronize { @active_jobs.values.count { |j| !j.finished? } }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def busy?
|
|
71
|
+
active_count.positive?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def create_job(&)
|
|
77
|
+
id = generate_id
|
|
78
|
+
job = Job.new(id, &)
|
|
79
|
+
@jobs_mutex.synchronize { @active_jobs[id] = job }
|
|
80
|
+
job
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def generate_id
|
|
84
|
+
@id_mutex.synchronize do
|
|
85
|
+
@next_id += 1
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def remove_job(id)
|
|
90
|
+
@jobs_mutex.synchronize { @active_jobs.delete(id) }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def invoke_callback(entry)
|
|
94
|
+
entry[:callback]&.call(entry[:job])
|
|
95
|
+
trigger_autocmd_event(entry[:job])
|
|
96
|
+
rescue StandardError => e
|
|
97
|
+
warn "Job callback error: #{e.message}"
|
|
98
|
+
warn e.backtrace.first(5).join("\n")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def trigger_autocmd_event(job)
|
|
102
|
+
return unless @autocmd
|
|
103
|
+
|
|
104
|
+
event = case job.status
|
|
105
|
+
when Job::Status::COMPLETED then :JobCompleted
|
|
106
|
+
when Job::Status::FAILED then :JobFailed
|
|
107
|
+
when Job::Status::CANCELLED then :JobCancelled
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
@autocmd.trigger(event, job:) if event
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
data/lib/mui/key_code.rb
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mui
|
|
4
|
+
# Key code constants for terminal input handling
|
|
5
|
+
module KeyCode
|
|
6
|
+
ESCAPE = 27
|
|
7
|
+
BACKSPACE = 127
|
|
8
|
+
ENTER_CR = 13
|
|
9
|
+
ENTER_LF = 10
|
|
10
|
+
TAB = 9
|
|
11
|
+
PRINTABLE_MIN = 32
|
|
12
|
+
# Extended to support Unicode characters (including CJK)
|
|
13
|
+
# 0x10FFFF is the maximum valid Unicode code point
|
|
14
|
+
PRINTABLE_MAX = 0x10FFFF
|
|
15
|
+
|
|
16
|
+
# Control key codes (Ctrl+letter)
|
|
17
|
+
CTRL_SPACE = 0 # Also Ctrl+@ (NUL)
|
|
18
|
+
CTRL_C = 3
|
|
19
|
+
CTRL_H = 8 # Also backspace in some terminals
|
|
20
|
+
CTRL_J = 10 # Also newline
|
|
21
|
+
CTRL_K = 11
|
|
22
|
+
CTRL_L = 12
|
|
23
|
+
CTRL_N = 14
|
|
24
|
+
CTRL_O = 15
|
|
25
|
+
CTRL_P = 16
|
|
26
|
+
CTRL_S = 19
|
|
27
|
+
CTRL_V = 22
|
|
28
|
+
CTRL_W = 23
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../error"
|
|
4
|
+
|
|
5
|
+
module Mui
|
|
6
|
+
module KeyHandler
|
|
7
|
+
class MethodNotOverriddenError < Mui::Error; end
|
|
8
|
+
|
|
9
|
+
# Base class for mode-specific key handlers
|
|
10
|
+
class Base
|
|
11
|
+
attr_accessor :mode_manager
|
|
12
|
+
|
|
13
|
+
def initialize(mode_manager, buffer)
|
|
14
|
+
@mode_manager = mode_manager
|
|
15
|
+
@buffer = buffer
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def window
|
|
19
|
+
@mode_manager&.active_window
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def buffer
|
|
23
|
+
window&.buffer || @buffer
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def editor
|
|
27
|
+
@mode_manager&.editor
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Handle a key input
|
|
31
|
+
def handle(_key)
|
|
32
|
+
raise MethodNotOverriddenError, "Subclasses must orverride #handle"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def cursor_row
|
|
38
|
+
window.cursor_row
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def cursor_col
|
|
42
|
+
window.cursor_col
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def cursor_row=(value)
|
|
46
|
+
window.cursor_row = value
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def cursor_col=(value)
|
|
50
|
+
window.cursor_col = value
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def current_line
|
|
54
|
+
buffer.line(cursor_row)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def current_line_length
|
|
58
|
+
current_line.length
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def extract_printable_char(key)
|
|
62
|
+
if key.is_a?(String)
|
|
63
|
+
# Curses returns multibyte characters as String
|
|
64
|
+
key
|
|
65
|
+
elsif key.is_a?(Integer) && key >= KeyCode::PRINTABLE_MIN && key <= KeyCode::PRINTABLE_MAX
|
|
66
|
+
# Use UTF-8 encoding to support Unicode characters
|
|
67
|
+
key.chr(Encoding::UTF_8)
|
|
68
|
+
end
|
|
69
|
+
rescue RangeError
|
|
70
|
+
# Invalid Unicode code point
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def key_to_char(key)
|
|
75
|
+
key.is_a?(String) ? key : key.chr
|
|
76
|
+
rescue RangeError
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def execute_pending_motion(char)
|
|
81
|
+
case @pending_motion
|
|
82
|
+
when :g
|
|
83
|
+
char == "g" ? Motion.file_start(buffer, cursor_row, cursor_col) : nil
|
|
84
|
+
when :f
|
|
85
|
+
Motion.find_char_forward(buffer, cursor_row, cursor_col, char)
|
|
86
|
+
when :F
|
|
87
|
+
Motion.find_char_backward(buffer, cursor_row, cursor_col, char)
|
|
88
|
+
when :t
|
|
89
|
+
Motion.till_char_forward(buffer, cursor_row, cursor_col, char)
|
|
90
|
+
when :T
|
|
91
|
+
Motion.till_char_backward(buffer, cursor_row, cursor_col, char)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def result(mode: nil, message: nil, quit: false)
|
|
96
|
+
HandlerResult::Base.new(mode:, message:, quit:)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|