mui 0.2.0 → 0.4.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 +18 -10
- data/CHANGELOG.md +162 -0
- data/README.md +309 -6
- data/docs/_config.yml +56 -0
- data/docs/configuration.md +314 -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 +155 -0
- data/lib/mui/color_manager.rb +140 -6
- data/lib/mui/color_scheme.rb +1 -0
- data/lib/mui/command_completer.rb +11 -2
- data/lib/mui/command_history.rb +89 -0
- data/lib/mui/command_line.rb +32 -2
- data/lib/mui/command_registry.rb +21 -2
- data/lib/mui/config.rb +3 -1
- data/lib/mui/editor.rb +90 -2
- data/lib/mui/floating_window.rb +53 -1
- data/lib/mui/handler_result.rb +13 -7
- data/lib/mui/highlighters/search_highlighter.rb +2 -1
- data/lib/mui/highlighters/syntax_highlighter.rb +4 -1
- data/lib/mui/insert_completion_state.rb +15 -2
- data/lib/mui/key_handler/base.rb +87 -0
- data/lib/mui/key_handler/command_mode.rb +68 -0
- data/lib/mui/key_handler/insert_mode.rb +10 -41
- data/lib/mui/key_handler/normal_mode.rb +24 -51
- data/lib/mui/key_handler/operators/paste_operator.rb +9 -3
- data/lib/mui/key_handler/search_mode.rb +10 -7
- data/lib/mui/key_handler/visual_mode.rb +15 -10
- data/lib/mui/key_notation_parser.rb +159 -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/line_renderer.rb +52 -1
- data/lib/mui/mode_manager.rb +3 -2
- data/lib/mui/screen.rb +30 -6
- data/lib/mui/search_state.rb +74 -27
- data/lib/mui/syntax/language_detector.rb +33 -1
- data/lib/mui/syntax/lexers/c_lexer.rb +2 -0
- data/lib/mui/syntax/lexers/css_lexer.rb +121 -0
- data/lib/mui/syntax/lexers/go_lexer.rb +207 -0
- data/lib/mui/syntax/lexers/html_lexer.rb +118 -0
- data/lib/mui/syntax/lexers/javascript_lexer.rb +219 -0
- data/lib/mui/syntax/lexers/markdown_lexer.rb +210 -0
- data/lib/mui/syntax/lexers/ruby_lexer.rb +3 -0
- data/lib/mui/syntax/lexers/rust_lexer.rb +150 -0
- data/lib/mui/syntax/lexers/typescript_lexer.rb +225 -0
- data/lib/mui/terminal_adapter/base.rb +21 -0
- data/lib/mui/terminal_adapter/curses.rb +37 -11
- data/lib/mui/themes/default.rb +263 -132
- data/lib/mui/version.rb +1 -1
- data/lib/mui/window.rb +105 -39
- data/lib/mui/window_manager.rb +7 -0
- data/lib/mui/wrap_cache.rb +40 -0
- data/lib/mui/wrap_helper.rb +84 -0
- data/lib/mui.rb +15 -0
- metadata +26 -3
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "strscan"
|
|
4
|
+
|
|
5
|
+
module Mui
|
|
6
|
+
# Parser for Vim-style key notation strings
|
|
7
|
+
# Converts notation like "<Leader>gd", "<C-x><C-s>", "<Space>w" to internal key arrays
|
|
8
|
+
module KeyNotationParser
|
|
9
|
+
# Special key mappings (case-insensitive)
|
|
10
|
+
SPECIAL_KEYS = {
|
|
11
|
+
"space" => " ",
|
|
12
|
+
"tab" => "\t",
|
|
13
|
+
"s-tab" => :shift_tab,
|
|
14
|
+
"btab" => :shift_tab,
|
|
15
|
+
"cr" => "\r",
|
|
16
|
+
"enter" => "\r",
|
|
17
|
+
"return" => "\r",
|
|
18
|
+
"esc" => "\e",
|
|
19
|
+
"escape" => "\e",
|
|
20
|
+
"bs" => "\x7f",
|
|
21
|
+
"backspace" => "\x7f",
|
|
22
|
+
"del" => "\x7f",
|
|
23
|
+
"delete" => "\x7f",
|
|
24
|
+
"lt" => "<",
|
|
25
|
+
"gt" => ">",
|
|
26
|
+
"bar" => "|",
|
|
27
|
+
"bslash" => "\\",
|
|
28
|
+
"leader" => :leader
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
# Ctrl key mappings (a-z and some special characters)
|
|
32
|
+
CTRL_CHARS = {
|
|
33
|
+
"@" => 0, # NUL
|
|
34
|
+
"a" => 1,
|
|
35
|
+
"b" => 2,
|
|
36
|
+
"c" => 3,
|
|
37
|
+
"d" => 4,
|
|
38
|
+
"e" => 5,
|
|
39
|
+
"f" => 6,
|
|
40
|
+
"g" => 7,
|
|
41
|
+
"h" => 8, # Also backspace
|
|
42
|
+
"i" => 9, # Also tab
|
|
43
|
+
"j" => 10, # Also newline
|
|
44
|
+
"k" => 11,
|
|
45
|
+
"l" => 12,
|
|
46
|
+
"m" => 13, # Also carriage return
|
|
47
|
+
"n" => 14,
|
|
48
|
+
"o" => 15,
|
|
49
|
+
"p" => 16,
|
|
50
|
+
"q" => 17,
|
|
51
|
+
"r" => 18,
|
|
52
|
+
"s" => 19,
|
|
53
|
+
"t" => 20,
|
|
54
|
+
"u" => 21,
|
|
55
|
+
"v" => 22,
|
|
56
|
+
"w" => 23,
|
|
57
|
+
"x" => 24,
|
|
58
|
+
"y" => 25,
|
|
59
|
+
"z" => 26,
|
|
60
|
+
"[" => 27, # Also escape
|
|
61
|
+
"\\" => 28,
|
|
62
|
+
"]" => 29,
|
|
63
|
+
"^" => 30,
|
|
64
|
+
"_" => 31
|
|
65
|
+
}.freeze
|
|
66
|
+
|
|
67
|
+
class << self
|
|
68
|
+
# Parse a key notation string into an array of keys
|
|
69
|
+
# @param notation [String] Key notation (e.g., "<Leader>gd", "<C-x><C-s>")
|
|
70
|
+
# @return [Array<String, Symbol>] Array of normalized keys
|
|
71
|
+
def parse(notation)
|
|
72
|
+
return [] if notation.nil? || notation.empty?
|
|
73
|
+
|
|
74
|
+
tokens = []
|
|
75
|
+
scanner = StringScanner.new(notation)
|
|
76
|
+
|
|
77
|
+
until scanner.eos?
|
|
78
|
+
if scanner.scan(/<([^>]+)>/)
|
|
79
|
+
# Special key notation <...>
|
|
80
|
+
tokens << parse_special(scanner[1])
|
|
81
|
+
else
|
|
82
|
+
# Regular character
|
|
83
|
+
char = scanner.getch
|
|
84
|
+
tokens << char if char
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
tokens
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Parse a special key notation (content inside < >)
|
|
92
|
+
# @param name [String] Special key name (e.g., "C-x", "Leader", "Space")
|
|
93
|
+
# @return [String, Symbol] Normalized key
|
|
94
|
+
def parse_special(name)
|
|
95
|
+
return :leader if name.casecmp?("leader")
|
|
96
|
+
|
|
97
|
+
# Check SPECIAL_KEYS first (handles <S-Tab>, <btab>, etc.)
|
|
98
|
+
normalized_name = name.downcase
|
|
99
|
+
return SPECIAL_KEYS[normalized_name] if SPECIAL_KEYS.key?(normalized_name)
|
|
100
|
+
|
|
101
|
+
# Handle Ctrl key: <C-x>, <Ctrl-x>, <C-X>
|
|
102
|
+
return parse_ctrl_key(::Regexp.last_match(2)) if name =~ /\A(c|ctrl)-(.+)\z/i
|
|
103
|
+
|
|
104
|
+
# Handle Shift key: <S-x>, <Shift-x>
|
|
105
|
+
return parse_shift_key(::Regexp.last_match(2)) if name =~ /\A(s|shift)-(.+)\z/i
|
|
106
|
+
|
|
107
|
+
# Unknown special key - return as-is
|
|
108
|
+
name
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Normalize an input key (from terminal) to internal representation
|
|
112
|
+
# @param key [Integer, String] Raw key input
|
|
113
|
+
# @return [String, nil] Normalized key string, or nil if invalid
|
|
114
|
+
def normalize_input_key(key)
|
|
115
|
+
case key
|
|
116
|
+
when String
|
|
117
|
+
key
|
|
118
|
+
when Integer
|
|
119
|
+
normalize_integer_key(key)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
def parse_ctrl_key(char)
|
|
126
|
+
char_lower = char.downcase
|
|
127
|
+
code = CTRL_CHARS[char_lower]
|
|
128
|
+
code ? code.chr : char
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def parse_shift_key(char)
|
|
132
|
+
# Shift typically produces uppercase for letters
|
|
133
|
+
char.length == 1 ? char.upcase : char
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def normalize_integer_key(key)
|
|
137
|
+
case key
|
|
138
|
+
when KeyCode::ENTER_CR, KeyCode::ENTER_LF
|
|
139
|
+
"\r"
|
|
140
|
+
when KeyCode::ESCAPE
|
|
141
|
+
"\e"
|
|
142
|
+
when KeyCode::TAB
|
|
143
|
+
"\t"
|
|
144
|
+
when 353 # Curses::KEY_BTAB (Shift+Tab)
|
|
145
|
+
:shift_tab
|
|
146
|
+
when KeyCode::BACKSPACE
|
|
147
|
+
"\x7f"
|
|
148
|
+
when 0..31
|
|
149
|
+
# Control characters - convert to the character they represent
|
|
150
|
+
key.chr
|
|
151
|
+
when KeyCode::PRINTABLE_MIN..KeyCode::PRINTABLE_MAX
|
|
152
|
+
key.chr(Encoding::UTF_8)
|
|
153
|
+
end
|
|
154
|
+
rescue RangeError
|
|
155
|
+
nil
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mui
|
|
4
|
+
# Represents a sequence of keys for key mapping
|
|
5
|
+
# Parses Vim-style notation and provides normalization for matching
|
|
6
|
+
class KeySequence
|
|
7
|
+
attr_reader :keys, :notation
|
|
8
|
+
|
|
9
|
+
# @param notation [String] Key notation string (e.g., "<Leader>gd", "<C-x><C-s>")
|
|
10
|
+
def initialize(notation)
|
|
11
|
+
@notation = notation
|
|
12
|
+
@keys = KeyNotationParser.parse(notation)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Normalize the key sequence by expanding :leader to actual leader key
|
|
16
|
+
# @param leader_key [String] The actual leader key (e.g., "\\", " ")
|
|
17
|
+
# @return [Array<String>] Array of normalized key strings
|
|
18
|
+
def normalize(leader_key)
|
|
19
|
+
@keys.map { |k| k == :leader ? leader_key : k }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Get the length of the key sequence
|
|
23
|
+
# @return [Integer] Number of keys in the sequence
|
|
24
|
+
def length
|
|
25
|
+
@keys.length
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Check if this sequence contains a leader key
|
|
29
|
+
# @return [Boolean]
|
|
30
|
+
def leader?
|
|
31
|
+
@keys.include?(:leader)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Check if this is a single key sequence
|
|
35
|
+
# @return [Boolean]
|
|
36
|
+
def single_key?
|
|
37
|
+
@keys.length == 1
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Check equality with another KeySequence
|
|
41
|
+
# @param other [KeySequence]
|
|
42
|
+
# @return [Boolean]
|
|
43
|
+
def ==(other)
|
|
44
|
+
return false unless other.is_a?(KeySequence)
|
|
45
|
+
|
|
46
|
+
@keys == other.keys
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
alias eql? ==
|
|
50
|
+
|
|
51
|
+
# Hash for use in Hash keys
|
|
52
|
+
def hash
|
|
53
|
+
@keys.hash
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Convert back to notation string for display
|
|
57
|
+
# @return [String]
|
|
58
|
+
def to_s
|
|
59
|
+
@notation
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Inspect for debugging
|
|
63
|
+
def inspect
|
|
64
|
+
"#<Mui::KeySequence #{@notation.inspect} => #{@keys.inspect}>"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mui
|
|
4
|
+
# Buffer for accumulating key inputs for multi-key sequence matching
|
|
5
|
+
# Tracks timing for timeout detection
|
|
6
|
+
class KeySequenceBuffer
|
|
7
|
+
attr_reader :last_input_time
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@buffer = []
|
|
11
|
+
@last_input_time = nil
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Push a key into the buffer
|
|
15
|
+
# @param key [Integer, String] Raw key input from terminal
|
|
16
|
+
# @return [Boolean] true if key was added, false if key was invalid
|
|
17
|
+
def push(key)
|
|
18
|
+
normalized = KeyNotationParser.normalize_input_key(key)
|
|
19
|
+
return false unless normalized
|
|
20
|
+
|
|
21
|
+
@buffer << normalized
|
|
22
|
+
@last_input_time = Time.now
|
|
23
|
+
true
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Clear the buffer
|
|
27
|
+
def clear
|
|
28
|
+
@buffer.clear
|
|
29
|
+
@last_input_time = nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Check if buffer is empty
|
|
33
|
+
# @return [Boolean]
|
|
34
|
+
def empty?
|
|
35
|
+
@buffer.empty?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Get buffer length
|
|
39
|
+
# @return [Integer]
|
|
40
|
+
def length
|
|
41
|
+
@buffer.length
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
alias size length
|
|
45
|
+
|
|
46
|
+
# Get copy of buffer contents as array
|
|
47
|
+
# @return [Array<String>]
|
|
48
|
+
def to_a
|
|
49
|
+
@buffer.dup
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Get the first key in the buffer
|
|
53
|
+
# @return [String, nil]
|
|
54
|
+
def first
|
|
55
|
+
@buffer.first
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Remove and return the first key
|
|
59
|
+
# @return [String, nil]
|
|
60
|
+
def shift
|
|
61
|
+
key = @buffer.shift
|
|
62
|
+
@last_input_time = nil if @buffer.empty?
|
|
63
|
+
key
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Check if the buffer has timed out
|
|
67
|
+
# @param timeout_ms [Integer] Timeout in milliseconds
|
|
68
|
+
# @return [Boolean]
|
|
69
|
+
def timeout?(timeout_ms)
|
|
70
|
+
return false unless @last_input_time
|
|
71
|
+
return false if @buffer.empty?
|
|
72
|
+
|
|
73
|
+
elapsed_ms = (Time.now - @last_input_time) * 1000
|
|
74
|
+
elapsed_ms > timeout_ms
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Get elapsed time since last input in milliseconds
|
|
78
|
+
# @return [Float, nil] Elapsed time in ms, or nil if no input
|
|
79
|
+
def elapsed_ms
|
|
80
|
+
return nil unless @last_input_time
|
|
81
|
+
|
|
82
|
+
(Time.now - @last_input_time) * 1000
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mui
|
|
4
|
+
# Main handler for multi-key sequence processing
|
|
5
|
+
# Integrates buffer, matcher, and timeout handling
|
|
6
|
+
class KeySequenceHandler
|
|
7
|
+
DEFAULT_TIMEOUT_MS = 1000
|
|
8
|
+
|
|
9
|
+
# Process result types
|
|
10
|
+
RESULT_HANDLED = :handled # Handler executed
|
|
11
|
+
RESULT_PENDING = :pending # Waiting for more keys
|
|
12
|
+
RESULT_PASSTHROUGH = :passthrough # No match, pass key to built-in handler
|
|
13
|
+
|
|
14
|
+
attr_reader :buffer
|
|
15
|
+
|
|
16
|
+
# @param config [Config] Configuration object
|
|
17
|
+
def initialize(config)
|
|
18
|
+
@config = config
|
|
19
|
+
@buffer = KeySequenceBuffer.new
|
|
20
|
+
@keymaps = {} # { mode => { KeySequence => handler } }
|
|
21
|
+
@pending_handler = nil # Handler for exact match while waiting for longer
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Get the leader key from config
|
|
25
|
+
# @return [String]
|
|
26
|
+
def leader_key
|
|
27
|
+
@config.get(:leader) || "\\"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Get the timeout in milliseconds
|
|
31
|
+
# @return [Integer]
|
|
32
|
+
def timeout_ms
|
|
33
|
+
@config.get(:timeoutlen) || DEFAULT_TIMEOUT_MS
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Register a key sequence mapping
|
|
37
|
+
# @param mode [Symbol] Mode (:normal, :insert, etc.)
|
|
38
|
+
# @param key_notation [String] Key notation (e.g., "<Leader>gd")
|
|
39
|
+
# @param handler [Proc] Handler to execute
|
|
40
|
+
def register(mode, key_notation, handler)
|
|
41
|
+
@keymaps[mode] ||= {}
|
|
42
|
+
sequence = KeySequence.new(key_notation)
|
|
43
|
+
@keymaps[mode][sequence] = handler
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Process an input key
|
|
47
|
+
# @param key [Integer, String] Raw key input
|
|
48
|
+
# @param mode [Symbol] Current mode
|
|
49
|
+
# @return [Array<Symbol, Object>] [result_type, data]
|
|
50
|
+
# - [:handled, handler] - Execute the handler
|
|
51
|
+
# - [:pending, nil] - Wait for more input
|
|
52
|
+
# - [:passthrough, key] - Pass key to built-in handler
|
|
53
|
+
def process(key, mode)
|
|
54
|
+
# Check timeout first - if timed out, handle before processing new key
|
|
55
|
+
if @buffer.timeout?(timeout_ms) && !@buffer.empty?
|
|
56
|
+
result = handle_timeout(mode)
|
|
57
|
+
# If we got a result, return it; the new key will be processed next time
|
|
58
|
+
return result if result[0] == RESULT_HANDLED
|
|
59
|
+
|
|
60
|
+
# If passthrough, clear buffer and continue with new key
|
|
61
|
+
@buffer.clear
|
|
62
|
+
@pending_handler = nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Add key to buffer
|
|
66
|
+
unless @buffer.push(key)
|
|
67
|
+
# Invalid key, pass through as-is
|
|
68
|
+
return [RESULT_PASSTHROUGH, key]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Match against keymaps
|
|
72
|
+
matcher = KeySequenceMatcher.new(@keymaps, leader_key)
|
|
73
|
+
match_type, handler = matcher.match(mode, @buffer.to_a)
|
|
74
|
+
|
|
75
|
+
case match_type
|
|
76
|
+
when KeySequenceMatcher::MATCH_EXACT
|
|
77
|
+
has_longer = matcher.longer_sequences?(mode, @buffer.to_a)
|
|
78
|
+
if has_longer
|
|
79
|
+
# Exact match but longer sequences exist
|
|
80
|
+
# Store handler and wait for more input or timeout
|
|
81
|
+
@pending_handler = handler
|
|
82
|
+
[RESULT_PENDING, nil]
|
|
83
|
+
else
|
|
84
|
+
# Exact match, no longer sequences - execute immediately
|
|
85
|
+
@buffer.clear
|
|
86
|
+
@pending_handler = nil
|
|
87
|
+
[RESULT_HANDLED, handler]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
when KeySequenceMatcher::MATCH_PARTIAL
|
|
91
|
+
# Could become a match, wait for more input
|
|
92
|
+
[RESULT_PENDING, nil]
|
|
93
|
+
|
|
94
|
+
else
|
|
95
|
+
# No match - pass through first key
|
|
96
|
+
handle_no_match
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Check for timeout and handle if needed (called from main loop)
|
|
101
|
+
# @param mode [Symbol] Current mode
|
|
102
|
+
# @return [Array<Symbol, Object>, nil] Result if timed out, nil otherwise
|
|
103
|
+
def check_timeout(mode)
|
|
104
|
+
return nil if @buffer.empty?
|
|
105
|
+
return nil unless @buffer.timeout?(timeout_ms)
|
|
106
|
+
|
|
107
|
+
handle_timeout(mode)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Check if there are pending keys in the buffer
|
|
111
|
+
# @return [Boolean]
|
|
112
|
+
def pending?
|
|
113
|
+
!@buffer.empty?
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Clear the buffer and pending state
|
|
117
|
+
def clear
|
|
118
|
+
@buffer.clear
|
|
119
|
+
@pending_handler = nil
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Get pending key display for status line
|
|
123
|
+
# @return [String, nil]
|
|
124
|
+
def pending_keys_display
|
|
125
|
+
return nil if @buffer.empty?
|
|
126
|
+
|
|
127
|
+
@buffer.to_a.join
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Rebuild internal keymaps from config
|
|
131
|
+
# Called after config changes
|
|
132
|
+
def rebuild_keymaps
|
|
133
|
+
@keymaps = {}
|
|
134
|
+
@config.keymaps.each do |mode, mappings|
|
|
135
|
+
mappings.each do |key_notation, handler|
|
|
136
|
+
register(mode, key_notation, handler)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
def handle_timeout(_mode)
|
|
144
|
+
if @pending_handler
|
|
145
|
+
# We had an exact match, execute it
|
|
146
|
+
handler = @pending_handler
|
|
147
|
+
@buffer.clear
|
|
148
|
+
@pending_handler = nil
|
|
149
|
+
[RESULT_HANDLED, handler]
|
|
150
|
+
else
|
|
151
|
+
# No exact match, passthrough first key
|
|
152
|
+
handle_no_match
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def handle_no_match
|
|
157
|
+
first_key = @buffer.shift
|
|
158
|
+
@buffer.clear
|
|
159
|
+
@pending_handler = nil
|
|
160
|
+
[RESULT_PASSTHROUGH, first_key]
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mui
|
|
4
|
+
# Matches input key sequences against registered key mappings
|
|
5
|
+
class KeySequenceMatcher
|
|
6
|
+
# Match result types
|
|
7
|
+
MATCH_EXACT = :exact # Complete match found
|
|
8
|
+
MATCH_PARTIAL = :partial # Input is prefix of one or more registered sequences
|
|
9
|
+
MATCH_NONE = :none # No match possible
|
|
10
|
+
|
|
11
|
+
# @param keymaps [Hash] Mode => { KeySequence => handler }
|
|
12
|
+
# @param leader_key [String] The leader key to expand :leader symbols
|
|
13
|
+
def initialize(keymaps, leader_key)
|
|
14
|
+
@keymaps = keymaps
|
|
15
|
+
@leader_key = leader_key
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Match input keys against registered keymaps for a mode
|
|
19
|
+
# @param mode [Symbol] The current mode (:normal, :insert, etc.)
|
|
20
|
+
# @param input_keys [Array<String>] Array of normalized input keys
|
|
21
|
+
# @return [Array<Symbol, Object>] [match_type, handler_or_nil]
|
|
22
|
+
def match(mode, input_keys)
|
|
23
|
+
mode_keymaps = @keymaps[mode]
|
|
24
|
+
return [MATCH_NONE, nil] unless mode_keymaps
|
|
25
|
+
return [MATCH_NONE, nil] if input_keys.empty?
|
|
26
|
+
|
|
27
|
+
exact_match = nil
|
|
28
|
+
has_longer_match = false
|
|
29
|
+
|
|
30
|
+
mode_keymaps.each do |sequence, handler|
|
|
31
|
+
seq_keys = sequence.normalize(@leader_key)
|
|
32
|
+
|
|
33
|
+
if seq_keys == input_keys
|
|
34
|
+
# Exact match found
|
|
35
|
+
exact_match = handler
|
|
36
|
+
elsif prefix_match?(input_keys, seq_keys)
|
|
37
|
+
# Input is a prefix of this sequence (longer sequence exists)
|
|
38
|
+
has_longer_match = true
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
if exact_match
|
|
43
|
+
# Exact match found - return it
|
|
44
|
+
# If there are also longer matches, caller may want to wait for timeout
|
|
45
|
+
[MATCH_EXACT, exact_match]
|
|
46
|
+
elsif has_longer_match
|
|
47
|
+
# No exact match, but input could lead to a match
|
|
48
|
+
[MATCH_PARTIAL, nil]
|
|
49
|
+
else
|
|
50
|
+
# No match possible
|
|
51
|
+
[MATCH_NONE, nil]
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Check if there are any longer sequences that could match
|
|
56
|
+
# Used to determine if we should wait for more input
|
|
57
|
+
# @param mode [Symbol]
|
|
58
|
+
# @param input_keys [Array<String>]
|
|
59
|
+
# @return [Boolean]
|
|
60
|
+
def longer_sequences?(mode, input_keys)
|
|
61
|
+
mode_keymaps = @keymaps[mode]
|
|
62
|
+
return false unless mode_keymaps
|
|
63
|
+
|
|
64
|
+
mode_keymaps.any? do |sequence, _handler|
|
|
65
|
+
seq_keys = sequence.normalize(@leader_key)
|
|
66
|
+
seq_keys.length > input_keys.length && prefix_match?(input_keys, seq_keys)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
# Check if input_keys is a prefix of seq_keys
|
|
73
|
+
def prefix_match?(input_keys, seq_keys)
|
|
74
|
+
return false if input_keys.length >= seq_keys.length
|
|
75
|
+
|
|
76
|
+
seq_keys.take(input_keys.length) == input_keys
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
data/lib/mui/line_renderer.rb
CHANGED
|
@@ -17,8 +17,59 @@ module Mui
|
|
|
17
17
|
render_with_highlights(screen, line, x, y, highlights)
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
+
# Renders a wrapped line segment with screen coordinate-based highlights
|
|
21
|
+
# wrap_info: { text:, start_col:, end_col: }
|
|
22
|
+
# options: { selection:, search_state:, logical_row:, visible_width: }
|
|
23
|
+
def render_wrapped_line(screen, y, x, wrap_info, options = {})
|
|
24
|
+
text = wrap_info[:text]
|
|
25
|
+
return if text.nil?
|
|
26
|
+
|
|
27
|
+
logical_row = options[:logical_row]
|
|
28
|
+
start_col = wrap_info[:start_col]
|
|
29
|
+
end_col = wrap_info[:end_col]
|
|
30
|
+
|
|
31
|
+
# Collect highlights for this row and clip to wrapped segment range
|
|
32
|
+
highlights = collect_highlights(logical_row, text, options)
|
|
33
|
+
clipped_highlights = clip_highlights_to_range(highlights, start_col, end_col)
|
|
34
|
+
|
|
35
|
+
# Adjust highlight positions to be relative to wrap segment start
|
|
36
|
+
adjusted_highlights = clipped_highlights.map do |h|
|
|
37
|
+
adjusted_start = h.start_col - start_col
|
|
38
|
+
adjusted_end = h.end_col - start_col
|
|
39
|
+
Highlight.new(
|
|
40
|
+
start_col: adjusted_start,
|
|
41
|
+
end_col: adjusted_end,
|
|
42
|
+
style: h.style,
|
|
43
|
+
priority: h.priority
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
render_with_highlights(screen, text, x, y, adjusted_highlights)
|
|
48
|
+
end
|
|
49
|
+
|
|
20
50
|
private
|
|
21
51
|
|
|
52
|
+
# Clips highlights to a column range and returns only overlapping portions
|
|
53
|
+
def clip_highlights_to_range(highlights, range_start, range_end)
|
|
54
|
+
highlights.filter_map do |h|
|
|
55
|
+
# Skip if highlight doesn't overlap with range
|
|
56
|
+
next if h.end_col < range_start || h.start_col >= range_end
|
|
57
|
+
|
|
58
|
+
# Clip to range
|
|
59
|
+
clipped_start = [h.start_col, range_start].max
|
|
60
|
+
clipped_end = [h.end_col, range_end - 1].min
|
|
61
|
+
|
|
62
|
+
next if clipped_start > clipped_end
|
|
63
|
+
|
|
64
|
+
Highlight.new(
|
|
65
|
+
start_col: clipped_start,
|
|
66
|
+
end_col: clipped_end,
|
|
67
|
+
style: h.style,
|
|
68
|
+
priority: h.priority
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
22
73
|
def collect_highlights(row, line, options)
|
|
23
74
|
@highlighters
|
|
24
75
|
.flat_map { |h| h.highlights_for(row, line, options) }
|
|
@@ -36,7 +87,7 @@ module Mui
|
|
|
36
87
|
|
|
37
88
|
segments.each do |segment|
|
|
38
89
|
put_text(screen, y, current_x, segment[:text], segment[:style])
|
|
39
|
-
current_x += segment[:text]
|
|
90
|
+
current_x += UnicodeWidth.string_width(segment[:text])
|
|
40
91
|
end
|
|
41
92
|
end
|
|
42
93
|
|
data/lib/mui/mode_manager.rb
CHANGED
|
@@ -4,9 +4,9 @@ module Mui
|
|
|
4
4
|
# Manages editor mode state and transitions
|
|
5
5
|
class ModeManager
|
|
6
6
|
attr_reader :mode, :selection, :register, :undo_manager, :search_state, :search_input, :editor,
|
|
7
|
-
:last_visual_selection
|
|
7
|
+
:last_visual_selection, :key_sequence_handler
|
|
8
8
|
|
|
9
|
-
def initialize(window:, buffer:, command_line:, undo_manager: nil, editor: nil, register: nil)
|
|
9
|
+
def initialize(window:, buffer:, command_line:, undo_manager: nil, editor: nil, register: nil, key_sequence_handler: nil)
|
|
10
10
|
@tab_manager = window.is_a?(TabManager) ? window : nil
|
|
11
11
|
@window_manager = window.is_a?(WindowManager) ? window : nil
|
|
12
12
|
@window = !@tab_manager && !@window_manager ? window : nil
|
|
@@ -15,6 +15,7 @@ module Mui
|
|
|
15
15
|
@register = register || Mui.register
|
|
16
16
|
@undo_manager = undo_manager
|
|
17
17
|
@editor = editor
|
|
18
|
+
@key_sequence_handler = key_sequence_handler
|
|
18
19
|
@search_state = SearchState.new
|
|
19
20
|
@search_input = SearchInput.new
|
|
20
21
|
@mode = Mode::NORMAL
|