mui 0.2.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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +13 -8
  3. data/CHANGELOG.md +99 -0
  4. data/README.md +309 -6
  5. data/docs/_config.yml +56 -0
  6. data/docs/configuration.md +301 -0
  7. data/docs/getting-started.md +140 -0
  8. data/docs/index.md +55 -0
  9. data/docs/jobs.md +297 -0
  10. data/docs/keybindings.md +229 -0
  11. data/docs/plugins.md +285 -0
  12. data/docs/syntax-highlighting.md +149 -0
  13. data/lib/mui/command_completer.rb +11 -2
  14. data/lib/mui/command_history.rb +89 -0
  15. data/lib/mui/command_line.rb +32 -2
  16. data/lib/mui/command_registry.rb +21 -2
  17. data/lib/mui/config.rb +3 -1
  18. data/lib/mui/editor.rb +78 -2
  19. data/lib/mui/handler_result.rb +13 -7
  20. data/lib/mui/highlighters/search_highlighter.rb +2 -1
  21. data/lib/mui/highlighters/syntax_highlighter.rb +3 -1
  22. data/lib/mui/key_handler/base.rb +87 -0
  23. data/lib/mui/key_handler/command_mode.rb +68 -0
  24. data/lib/mui/key_handler/insert_mode.rb +10 -41
  25. data/lib/mui/key_handler/normal_mode.rb +24 -51
  26. data/lib/mui/key_handler/operators/paste_operator.rb +9 -3
  27. data/lib/mui/key_handler/search_mode.rb +10 -7
  28. data/lib/mui/key_handler/visual_mode.rb +15 -10
  29. data/lib/mui/key_notation_parser.rb +152 -0
  30. data/lib/mui/key_sequence.rb +67 -0
  31. data/lib/mui/key_sequence_buffer.rb +85 -0
  32. data/lib/mui/key_sequence_handler.rb +163 -0
  33. data/lib/mui/key_sequence_matcher.rb +79 -0
  34. data/lib/mui/line_renderer.rb +52 -1
  35. data/lib/mui/mode_manager.rb +3 -2
  36. data/lib/mui/screen.rb +24 -6
  37. data/lib/mui/search_state.rb +61 -28
  38. data/lib/mui/syntax/language_detector.rb +33 -1
  39. data/lib/mui/syntax/lexers/css_lexer.rb +121 -0
  40. data/lib/mui/syntax/lexers/go_lexer.rb +205 -0
  41. data/lib/mui/syntax/lexers/html_lexer.rb +118 -0
  42. data/lib/mui/syntax/lexers/javascript_lexer.rb +197 -0
  43. data/lib/mui/syntax/lexers/markdown_lexer.rb +210 -0
  44. data/lib/mui/syntax/lexers/rust_lexer.rb +148 -0
  45. data/lib/mui/syntax/lexers/typescript_lexer.rb +203 -0
  46. data/lib/mui/terminal_adapter/curses.rb +13 -11
  47. data/lib/mui/version.rb +1 -1
  48. data/lib/mui/window.rb +83 -40
  49. data/lib/mui/window_manager.rb +7 -0
  50. data/lib/mui/wrap_cache.rb +40 -0
  51. data/lib/mui/wrap_helper.rb +84 -0
  52. data/lib/mui.rb +15 -0
  53. metadata +26 -3
@@ -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
@@ -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].length
90
+ current_x += UnicodeWidth.string_width(segment[:text])
40
91
  end
41
92
  end
42
93
 
@@ -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
data/lib/mui/screen.rb CHANGED
@@ -30,8 +30,8 @@ module Mui
30
30
  return if y >= @height || x >= @width
31
31
 
32
32
  @adapter.setpos(y, x)
33
- max_len = @width - x
34
- @adapter.addstr(text.length > max_len ? text[0, max_len] : text)
33
+ max_width = @width - x
34
+ @adapter.addstr(truncate_to_width(text, max_width))
35
35
  end
36
36
 
37
37
  def put_with_highlight(y, x, text)
@@ -39,9 +39,9 @@ module Mui
39
39
  return if y >= @height || x >= @width
40
40
 
41
41
  @adapter.setpos(y, x)
42
- max_len = @width - x
42
+ max_width = @width - x
43
43
  @adapter.with_highlight do
44
- @adapter.addstr(text.length > max_len ? text[0, max_len] : text)
44
+ @adapter.addstr(truncate_to_width(text, max_width))
45
45
  end
46
46
  end
47
47
 
@@ -51,8 +51,8 @@ module Mui
51
51
  return put(y, x, text) unless @color_manager && style
52
52
 
53
53
  @adapter.setpos(y, x)
54
- max_len = @width - x
55
- truncated_text = text.length > max_len ? text[0, max_len] : text
54
+ max_width = @width - x
55
+ truncated_text = truncate_to_width(text, max_width)
56
56
 
57
57
  pair_index = ensure_color_pair(style[:fg], style[:bg])
58
58
  @adapter.with_color(pair_index, bold: style[:bold], underline: style[:underline]) do
@@ -81,5 +81,23 @@ module Mui
81
81
  @width = @adapter.width
82
82
  @height = @adapter.height
83
83
  end
84
+
85
+ # Truncates text to fit within max_width display columns
86
+ def truncate_to_width(text, max_width)
87
+ return text if max_width <= 0
88
+
89
+ current_width = 0
90
+ result = String.new
91
+
92
+ text.each_char do |char|
93
+ char_w = UnicodeWidth.char_width(char)
94
+ break if current_width + char_w > max_width
95
+
96
+ result << char
97
+ current_width += char_w
98
+ end
99
+
100
+ result
101
+ end
84
102
  end
85
103
  end
@@ -2,81 +2,114 @@
2
2
 
3
3
  module Mui
4
4
  class SearchState
5
- attr_reader :pattern, :direction, :matches
5
+ attr_reader :pattern, :direction
6
6
 
7
7
  def initialize
8
8
  @pattern = nil
9
9
  @direction = :forward
10
- @matches = []
10
+ @pattern_version = 0
11
+ @buffer_matches = {} # { buffer_object_id => { version:, matches: [] } }
11
12
  end
12
13
 
13
14
  def set_pattern(pattern, direction)
14
15
  @pattern = pattern
15
16
  @direction = direction
16
- @matches = []
17
+ @pattern_version += 1
18
+ @buffer_matches.clear # Invalidate all cached matches
17
19
  end
18
20
 
21
+ # Calculate matches for a specific buffer (used for n/N navigation)
19
22
  def find_all_matches(buffer)
20
- @matches = []
21
- return if @pattern.nil? || @pattern.empty?
23
+ return [] if @pattern.nil? || @pattern.empty? || buffer.nil?
22
24
 
23
- begin
24
- regex = Regexp.new(@pattern)
25
- buffer.line_count.times do |row|
26
- line = buffer.line(row)
27
- scan_line_matches(line, row, regex)
28
- end
29
- rescue RegexpError
30
- # Invalid regex pattern - no matches
31
- @matches = []
32
- end
25
+ get_or_calculate_matches(buffer)
33
26
  end
34
27
 
35
- def find_next(current_row, current_col)
36
- return nil if @matches.empty?
28
+ def find_next(current_row, current_col, buffer: nil)
29
+ matches = buffer ? get_or_calculate_matches(buffer) : []
30
+ return nil if matches.empty?
37
31
 
38
32
  # Find next match after current position
39
- match = @matches.find do |m|
33
+ match = matches.find do |m|
40
34
  m[:row] > current_row || (m[:row] == current_row && m[:col] > current_col)
41
35
  end
42
36
 
43
37
  # Wrap around to beginning if no match found
44
- match || @matches.first
38
+ match || matches.first
45
39
  end
46
40
 
47
- def find_previous(current_row, current_col)
48
- return nil if @matches.empty?
41
+ def find_previous(current_row, current_col, buffer: nil)
42
+ matches = buffer ? get_or_calculate_matches(buffer) : []
43
+ return nil if matches.empty?
49
44
 
50
45
  # Find previous match before current position
51
- match = @matches.reverse.find do |m|
46
+ match = matches.reverse.find do |m|
52
47
  m[:row] < current_row || (m[:row] == current_row && m[:col] < current_col)
53
48
  end
54
49
 
55
50
  # Wrap around to end if no match found
56
- match || @matches.last
51
+ match || matches.last
57
52
  end
58
53
 
59
54
  def clear
60
55
  @pattern = nil
61
- @matches = []
56
+ @pattern_version += 1
57
+ @buffer_matches.clear
62
58
  end
63
59
 
64
60
  def has_pattern?
65
61
  !@pattern.nil? && !@pattern.empty?
66
62
  end
67
63
 
68
- def matches_for_row(row)
69
- @matches.select { |m| m[:row] == row }
64
+ # Get matches for a specific row in a specific buffer
65
+ def matches_for_row(row, buffer: nil)
66
+ return [] if buffer.nil?
67
+
68
+ matches = get_or_calculate_matches(buffer)
69
+ matches.select { |m| m[:row] == row }
70
70
  end
71
71
 
72
72
  private
73
73
 
74
- def scan_line_matches(line, row, regex)
74
+ def get_or_calculate_matches(buffer)
75
+ buffer_id = buffer.object_id
76
+ cached = @buffer_matches[buffer_id]
77
+
78
+ # Return cached matches if valid (same pattern version and buffer hasn't changed)
79
+ return cached[:matches] if cached && cached[:version] == @pattern_version && cached[:change_count] == buffer.change_count
80
+
81
+ # Calculate and cache matches for this buffer
82
+ matches = calculate_matches(buffer)
83
+ @buffer_matches[buffer_id] = {
84
+ version: @pattern_version,
85
+ change_count: buffer.change_count,
86
+ matches:
87
+ }
88
+ matches
89
+ end
90
+
91
+ def calculate_matches(buffer)
92
+ return [] if @pattern.nil? || @pattern.empty?
93
+
94
+ matches = []
95
+ begin
96
+ regex = Regexp.new(@pattern)
97
+ buffer.line_count.times do |row|
98
+ line = buffer.line(row)
99
+ scan_line_matches(matches, line, row, regex)
100
+ end
101
+ rescue RegexpError
102
+ # Invalid regex pattern - no matches
103
+ end
104
+ matches
105
+ end
106
+
107
+ def scan_line_matches(matches, line, row, regex)
75
108
  offset = 0
76
109
  while (match_data = line.match(regex, offset))
77
110
  col = match_data.begin(0)
78
111
  end_col = match_data.end(0) - 1
79
- @matches << { row:, col:, end_col: }
112
+ matches << { row:, col:, end_col: }
80
113
  # Move offset past the end of the match to avoid overlapping matches
81
114
  offset = match_data.end(0)
82
115
  # Handle zero-length matches to prevent infinite loop
@@ -12,7 +12,25 @@ module Mui
12
12
  ".gemspec" => :ruby,
13
13
  ".c" => :c,
14
14
  ".h" => :c,
15
- ".y" => :c
15
+ ".y" => :c,
16
+ ".go" => :go,
17
+ ".rs" => :rust,
18
+ ".js" => :javascript,
19
+ ".mjs" => :javascript,
20
+ ".cjs" => :javascript,
21
+ ".jsx" => :javascript,
22
+ ".ts" => :typescript,
23
+ ".tsx" => :typescript,
24
+ ".mts" => :typescript,
25
+ ".cts" => :typescript,
26
+ ".md" => :markdown,
27
+ ".markdown" => :markdown,
28
+ ".html" => :html,
29
+ ".htm" => :html,
30
+ ".xhtml" => :html,
31
+ ".css" => :css,
32
+ ".scss" => :css,
33
+ ".sass" => :css
16
34
  }.freeze
17
35
 
18
36
  # Map basenames (files without extension) to language symbols
@@ -50,6 +68,20 @@ module Mui
50
68
  Lexers::RubyLexer.new
51
69
  when :c
52
70
  Lexers::CLexer.new
71
+ when :go
72
+ Lexers::GoLexer.new
73
+ when :rust
74
+ Lexers::RustLexer.new
75
+ when :javascript
76
+ Lexers::JavaScriptLexer.new
77
+ when :typescript
78
+ Lexers::TypeScriptLexer.new
79
+ when :markdown
80
+ Lexers::MarkdownLexer.new
81
+ when :html
82
+ Lexers::HtmlLexer.new
83
+ when :css
84
+ Lexers::CssLexer.new
53
85
  end
54
86
  end
55
87
 
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ module Syntax
5
+ module Lexers
6
+ # Lexer for CSS source files
7
+ class CssLexer < LexerBase
8
+ # Pre-compiled patterns with \G anchor for position-specific matching
9
+ COMPILED_PATTERNS = [
10
+ # Single-line block comment /* ... */ on one line
11
+ [:comment, %r{\G/\*.*?\*/}],
12
+ # @rules (at-rules)
13
+ [:preprocessor, /\G@[a-zA-Z-]+/],
14
+ # Hex color (must be before ID selector - matches 3-8 hex digits only)
15
+ [:number, /\G#[0-9a-fA-F]{3,8}(?![a-zA-Z0-9_-])/],
16
+ # ID selector (starts with letter or underscore/hyphen after #)
17
+ [:constant, /\G#[a-zA-Z_-][a-zA-Z0-9_-]*/],
18
+ # Class selector
19
+ [:type, /\G\.[a-zA-Z_-][a-zA-Z0-9_-]*/],
20
+ # Pseudo-elements and pseudo-classes
21
+ [:keyword, /\G::?[a-zA-Z-]+(?:\([^)]*\))?/],
22
+ # Property name (followed by colon)
23
+ [:identifier, /\G[a-zA-Z-]+(?=\s*:)/],
24
+ # Double quoted string
25
+ [:string, /\G"(?:[^"\\]|\\.)*"/],
26
+ # Single quoted string
27
+ [:string, /\G'(?:[^'\\]|\\.)*'/],
28
+ # URL function
29
+ [:string, /\Gurl\([^)]*\)/i],
30
+ # Numbers with units
31
+ [:number, /\G-?\d+\.?\d*(?:px|em|rem|%|vh|vw|vmin|vmax|ch|ex|cm|mm|in|pt|pc|deg|rad|grad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx|fr)?/i],
32
+ # Functions (calc, rgb, rgba, hsl, var, etc.)
33
+ [:keyword, /\G[a-zA-Z-]+(?=\()/],
34
+ # Property values / keywords (important, inherit, etc.)
35
+ [:constant, /\G!important\b/i],
36
+ [:constant, /\G\b(?:inherit|initial|unset|revert|none|auto|normal)\b/],
37
+ # Element selectors and identifiers
38
+ [:identifier, /\G[a-zA-Z_-][a-zA-Z0-9_-]*/],
39
+ # Operators and symbols
40
+ [:operator, /\G[{}():;,>+~*=\[\]]/]
41
+ ].freeze
42
+
43
+ # Multiline comment patterns
44
+ BLOCK_COMMENT_END = %r{\*/}
45
+ BLOCK_COMMENT_START = %r{/\*}
46
+ BLOCK_COMMENT_START_ANCHOR = %r{\A/\*}
47
+
48
+ protected
49
+
50
+ def compiled_patterns
51
+ COMPILED_PATTERNS
52
+ end
53
+
54
+ # Handle /* ... */ block comments that span multiple lines
55
+ def handle_multiline_state(line, pos, state)
56
+ return [nil, nil, pos] unless state == :block_comment
57
+
58
+ end_match = line[pos..].match(BLOCK_COMMENT_END)
59
+ if end_match
60
+ end_pos = pos + end_match.begin(0) + 1
61
+ text = line[pos..end_pos]
62
+ token = Token.new(
63
+ type: :comment,
64
+ start_col: pos,
65
+ end_col: end_pos,
66
+ text:
67
+ )
68
+ [token, nil, end_pos + 1]
69
+ else
70
+ text = line[pos..]
71
+ token = if text.empty?
72
+ nil
73
+ else
74
+ Token.new(
75
+ type: :comment,
76
+ start_col: pos,
77
+ end_col: line.length - 1,
78
+ text:
79
+ )
80
+ end
81
+ [token, :block_comment, line.length]
82
+ end
83
+ end
84
+
85
+ def check_multiline_start(line, pos)
86
+ rest = line[pos..]
87
+
88
+ start_match = rest.match(BLOCK_COMMENT_START)
89
+ return [nil, nil, pos] unless start_match
90
+
91
+ start_pos = pos + start_match.begin(0)
92
+ after_start = line[(start_pos + 2)..]
93
+
94
+ if after_start&.include?("*/")
95
+ [nil, nil, pos]
96
+ else
97
+ text = line[start_pos..]
98
+ token = Token.new(
99
+ type: :comment,
100
+ start_col: start_pos,
101
+ end_col: line.length - 1,
102
+ text:
103
+ )
104
+ [:block_comment, token, line.length]
105
+ end
106
+ end
107
+
108
+ private
109
+
110
+ def match_token(line, pos)
111
+ if line[pos..].match?(BLOCK_COMMENT_START_ANCHOR)
112
+ rest = line[(pos + 2)..]
113
+ return nil unless rest&.include?("*/")
114
+ end
115
+
116
+ super
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end