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.
Files changed (110) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +163 -0
  3. data/CHANGELOG.md +448 -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/exe/mui +1 -2
  14. data/lib/mui/autocmd.rb +66 -0
  15. data/lib/mui/buffer.rb +275 -0
  16. data/lib/mui/buffer_word_cache.rb +131 -0
  17. data/lib/mui/buffer_word_completer.rb +77 -0
  18. data/lib/mui/color_manager.rb +136 -0
  19. data/lib/mui/color_scheme.rb +63 -0
  20. data/lib/mui/command_completer.rb +30 -0
  21. data/lib/mui/command_context.rb +90 -0
  22. data/lib/mui/command_history.rb +89 -0
  23. data/lib/mui/command_line.rb +167 -0
  24. data/lib/mui/command_registry.rb +44 -0
  25. data/lib/mui/completion_renderer.rb +84 -0
  26. data/lib/mui/completion_state.rb +58 -0
  27. data/lib/mui/config.rb +58 -0
  28. data/lib/mui/editor.rb +395 -0
  29. data/lib/mui/error.rb +29 -0
  30. data/lib/mui/file_completer.rb +51 -0
  31. data/lib/mui/floating_window.rb +161 -0
  32. data/lib/mui/handler_result.rb +107 -0
  33. data/lib/mui/highlight.rb +22 -0
  34. data/lib/mui/highlighters/base.rb +23 -0
  35. data/lib/mui/highlighters/search_highlighter.rb +27 -0
  36. data/lib/mui/highlighters/selection_highlighter.rb +48 -0
  37. data/lib/mui/highlighters/syntax_highlighter.rb +107 -0
  38. data/lib/mui/input.rb +17 -0
  39. data/lib/mui/insert_completion_renderer.rb +92 -0
  40. data/lib/mui/insert_completion_state.rb +77 -0
  41. data/lib/mui/job.rb +81 -0
  42. data/lib/mui/job_manager.rb +113 -0
  43. data/lib/mui/key_code.rb +30 -0
  44. data/lib/mui/key_handler/base.rb +187 -0
  45. data/lib/mui/key_handler/command_mode.rb +511 -0
  46. data/lib/mui/key_handler/insert_mode.rb +323 -0
  47. data/lib/mui/key_handler/motions/motion_handler.rb +56 -0
  48. data/lib/mui/key_handler/normal_mode.rb +552 -0
  49. data/lib/mui/key_handler/operators/base_operator.rb +134 -0
  50. data/lib/mui/key_handler/operators/change_operator.rb +179 -0
  51. data/lib/mui/key_handler/operators/delete_operator.rb +176 -0
  52. data/lib/mui/key_handler/operators/paste_operator.rb +119 -0
  53. data/lib/mui/key_handler/operators/yank_operator.rb +127 -0
  54. data/lib/mui/key_handler/search_mode.rb +191 -0
  55. data/lib/mui/key_handler/visual_line_mode.rb +20 -0
  56. data/lib/mui/key_handler/visual_mode.rb +402 -0
  57. data/lib/mui/key_handler/window_command.rb +112 -0
  58. data/lib/mui/key_handler.rb +16 -0
  59. data/lib/mui/key_notation_parser.rb +152 -0
  60. data/lib/mui/key_sequence.rb +67 -0
  61. data/lib/mui/key_sequence_buffer.rb +85 -0
  62. data/lib/mui/key_sequence_handler.rb +163 -0
  63. data/lib/mui/key_sequence_matcher.rb +79 -0
  64. data/lib/mui/layout/calculator.rb +15 -0
  65. data/lib/mui/layout/leaf_node.rb +33 -0
  66. data/lib/mui/layout/node.rb +29 -0
  67. data/lib/mui/layout/split_node.rb +132 -0
  68. data/lib/mui/line_renderer.rb +173 -0
  69. data/lib/mui/mode.rb +13 -0
  70. data/lib/mui/mode_manager.rb +186 -0
  71. data/lib/mui/motion.rb +139 -0
  72. data/lib/mui/plugin.rb +35 -0
  73. data/lib/mui/plugin_manager.rb +106 -0
  74. data/lib/mui/register.rb +110 -0
  75. data/lib/mui/screen.rb +103 -0
  76. data/lib/mui/search_completer.rb +50 -0
  77. data/lib/mui/search_input.rb +40 -0
  78. data/lib/mui/search_state.rb +121 -0
  79. data/lib/mui/selection.rb +55 -0
  80. data/lib/mui/status_line_renderer.rb +40 -0
  81. data/lib/mui/syntax/language_detector.rb +106 -0
  82. data/lib/mui/syntax/lexer_base.rb +106 -0
  83. data/lib/mui/syntax/lexers/c_lexer.rb +127 -0
  84. data/lib/mui/syntax/lexers/css_lexer.rb +121 -0
  85. data/lib/mui/syntax/lexers/go_lexer.rb +205 -0
  86. data/lib/mui/syntax/lexers/html_lexer.rb +118 -0
  87. data/lib/mui/syntax/lexers/javascript_lexer.rb +197 -0
  88. data/lib/mui/syntax/lexers/markdown_lexer.rb +210 -0
  89. data/lib/mui/syntax/lexers/ruby_lexer.rb +114 -0
  90. data/lib/mui/syntax/lexers/rust_lexer.rb +148 -0
  91. data/lib/mui/syntax/lexers/typescript_lexer.rb +203 -0
  92. data/lib/mui/syntax/token.rb +42 -0
  93. data/lib/mui/syntax/token_cache.rb +91 -0
  94. data/lib/mui/tab_bar_renderer.rb +87 -0
  95. data/lib/mui/tab_manager.rb +96 -0
  96. data/lib/mui/tab_page.rb +35 -0
  97. data/lib/mui/terminal_adapter/base.rb +92 -0
  98. data/lib/mui/terminal_adapter/curses.rb +164 -0
  99. data/lib/mui/terminal_adapter.rb +4 -0
  100. data/lib/mui/themes/default.rb +315 -0
  101. data/lib/mui/undo_manager.rb +83 -0
  102. data/lib/mui/undoable_action.rb +175 -0
  103. data/lib/mui/unicode_width.rb +100 -0
  104. data/lib/mui/version.rb +1 -1
  105. data/lib/mui/window.rb +201 -0
  106. data/lib/mui/window_manager.rb +256 -0
  107. data/lib/mui/wrap_cache.rb +40 -0
  108. data/lib/mui/wrap_helper.rb +84 -0
  109. data/lib/mui.rb +171 -2
  110. metadata +123 -5
data/lib/mui/plugin.rb ADDED
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ # Base class for class-based plugins
5
+ class Plugin
6
+ class << self
7
+ attr_accessor :plugin_name, :plugin_dependencies
8
+
9
+ def name(n)
10
+ @plugin_name = n
11
+ end
12
+
13
+ def depends_on(*deps)
14
+ @plugin_dependencies = deps
15
+ end
16
+ end
17
+
18
+ def setup
19
+ # Override in subclass: plugin initialization
20
+ end
21
+
22
+ # API shortcuts
23
+ def command(name, &)
24
+ Mui.command(name, &)
25
+ end
26
+
27
+ def keymap(mode, key, &)
28
+ Mui.keymap(mode, key, &)
29
+ end
30
+
31
+ def autocmd(event, pattern: nil, &)
32
+ Mui.autocmd(event, pattern:, &)
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/inline"
4
+
5
+ module Mui
6
+ # Manages plugin lifecycle: loading, initialization, dependency resolution
7
+ class PluginManager
8
+ attr_reader :plugins, :loaded_plugins, :pending_gems
9
+
10
+ def initialize
11
+ @plugins = {} # Registered plugin definitions (DSL/class)
12
+ @loaded_plugins = [] # Loaded plugin names
13
+ @pending_gems = [] # Gems waiting to be installed
14
+ @installed = false
15
+ end
16
+
17
+ # Called from Mui.use - register gem (don't install yet)
18
+ def add_gem(gem_name, version = nil)
19
+ @pending_gems << { gem: gem_name, version: }
20
+ end
21
+
22
+ # Register plugin definition (DSL or class)
23
+ def register(name, plugin_class_or_block, dependencies: [])
24
+ @plugins[name.to_sym] = {
25
+ handler: plugin_class_or_block,
26
+ dependencies: dependencies.map(&:to_sym)
27
+ }
28
+ end
29
+
30
+ # Called during Editor initialization - install and load all at once
31
+ def install_and_load
32
+ return if @installed
33
+
34
+ install_gems unless @pending_gems.empty?
35
+ load_all_plugins
36
+ @installed = true
37
+ end
38
+
39
+ def installed?
40
+ @installed
41
+ end
42
+
43
+ private
44
+
45
+ def install_gems
46
+ gems = @pending_gems
47
+ gemfile do
48
+ source "https://rubygems.org"
49
+ gems.each do |g|
50
+ if g[:version]
51
+ gem g[:gem], g[:version]
52
+ else
53
+ gem g[:gem]
54
+ end
55
+ end
56
+ end
57
+ rescue Bundler::GemNotFound => e
58
+ warn "Plugin gem not found: #{e.message}"
59
+ rescue Gem::MissingSpecError => e
60
+ warn "Plugin gem not found: #{e.message}"
61
+ end
62
+
63
+ def load_all_plugins
64
+ # Sort by dependencies and load
65
+ sorted_plugins = topological_sort(@plugins)
66
+ sorted_plugins.each { |name| load_plugin(name) }
67
+ end
68
+
69
+ def load_plugin(name)
70
+ return if @loaded_plugins.include?(name)
71
+
72
+ plugin_def = @plugins[name]
73
+ return unless plugin_def
74
+
75
+ instance = instantiate_plugin(plugin_def[:handler])
76
+ instance.setup if instance.respond_to?(:setup)
77
+ @loaded_plugins << name
78
+ end
79
+
80
+ def instantiate_plugin(handler)
81
+ case handler
82
+ when Class
83
+ handler.new
84
+ when Proc
85
+ handler.call
86
+ nil
87
+ end
88
+ end
89
+
90
+ def topological_sort(plugins)
91
+ sorted = []
92
+ visited = {}
93
+
94
+ visit = lambda do |name|
95
+ return if visited[name]
96
+
97
+ visited[name] = true
98
+ plugins[name]&.[](:dependencies)&.each { |dep| visit.call(dep) }
99
+ sorted << name
100
+ end
101
+
102
+ plugins.each_key { |name| visit.call(name) }
103
+ sorted
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ # Manages yank/delete registers for copy/paste operations
5
+ # Supports Vim-compatible registers:
6
+ # - "" (unnamed): default register
7
+ # - "a-"z: named registers
8
+ # - "0: yank register (stores last yank, not affected by delete)
9
+ # - "1-"9: delete history (shifted on each delete)
10
+ # - "_: black hole register (discards content)
11
+ class Register
12
+ YANK_REGISTER = "0"
13
+ DELETE_HISTORY_REGISTERS = ("1".."9").to_a.freeze
14
+ BLACK_HOLE_REGISTER = "_"
15
+ UNNAMED_REGISTER = '"'
16
+ NAMED_REGISTERS = ("a".."z").to_a.freeze
17
+
18
+ def initialize
19
+ @unnamed = { content: nil, linewise: false }
20
+ @yank_register = { content: nil, linewise: false }
21
+ @delete_history = []
22
+ @named_registers = {}
23
+ end
24
+
25
+ # Store text from yank operation
26
+ # Saves to unnamed register and "0 (yank register)
27
+ def yank(text, linewise: false, name: nil)
28
+ return if name == BLACK_HOLE_REGISTER
29
+
30
+ if name && NAMED_REGISTERS.include?(name)
31
+ @named_registers[name] = { content: text, linewise: }
32
+ else
33
+ @unnamed = { content: text, linewise: }
34
+ @yank_register = { content: text, linewise: }
35
+ end
36
+ end
37
+
38
+ # Store text from delete operation
39
+ # Saves to unnamed register and shifts delete history ("1-"9)
40
+ def delete(text, linewise: false, name: nil)
41
+ return if name == BLACK_HOLE_REGISTER
42
+
43
+ if name && NAMED_REGISTERS.include?(name)
44
+ @named_registers[name] = { content: text, linewise: }
45
+ else
46
+ @unnamed = { content: text, linewise: }
47
+ shift_delete_history(text, linewise)
48
+ end
49
+ end
50
+
51
+ # Legacy method for backward compatibility
52
+ def set(text, linewise: false, name: nil)
53
+ if name
54
+ @named_registers[name] = { content: text, linewise: }
55
+ else
56
+ @unnamed = { content: text, linewise: }
57
+ end
58
+ end
59
+
60
+ def get(name: nil)
61
+ case name
62
+ when nil, UNNAMED_REGISTER
63
+ @unnamed[:content]
64
+ when YANK_REGISTER
65
+ @yank_register[:content]
66
+ when *DELETE_HISTORY_REGISTERS
67
+ index = name.to_i - 1
68
+ @delete_history[index]&.fetch(:content, nil)
69
+ when BLACK_HOLE_REGISTER
70
+ nil
71
+ when *NAMED_REGISTERS
72
+ @named_registers[name]&.fetch(:content, nil)
73
+ end
74
+ end
75
+
76
+ def linewise?(name: nil)
77
+ case name
78
+ when nil, UNNAMED_REGISTER
79
+ @unnamed[:linewise]
80
+ when YANK_REGISTER
81
+ @yank_register[:linewise]
82
+ when *DELETE_HISTORY_REGISTERS
83
+ index = name.to_i - 1
84
+ @delete_history[index]&.fetch(:linewise, false) || false
85
+ when BLACK_HOLE_REGISTER
86
+ false
87
+ when *NAMED_REGISTERS
88
+ @named_registers[name]&.fetch(:linewise, false) || false
89
+ else
90
+ false
91
+ end
92
+ end
93
+
94
+ def empty?(name: nil)
95
+ get(name:).nil?
96
+ end
97
+
98
+ # For backward compatibility
99
+ def linewise
100
+ @unnamed[:linewise]
101
+ end
102
+
103
+ private
104
+
105
+ def shift_delete_history(text, linewise)
106
+ @delete_history.unshift({ content: text, linewise: })
107
+ @delete_history = @delete_history.first(9)
108
+ end
109
+ end
110
+ end
data/lib/mui/screen.rb ADDED
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ class Screen
5
+ attr_reader :width, :height
6
+
7
+ def initialize(adapter:, color_manager: nil)
8
+ @adapter = adapter
9
+ @color_manager = color_manager
10
+ @initialized_pairs = {}
11
+ @adapter.init
12
+ update_size
13
+ end
14
+
15
+ def refresh
16
+ update_size
17
+ @adapter.refresh
18
+ end
19
+
20
+ def close
21
+ @adapter.close
22
+ end
23
+
24
+ def clear
25
+ @adapter.clear
26
+ end
27
+
28
+ def put(y, x, text)
29
+ return if y.negative?
30
+ return if y >= @height || x >= @width
31
+
32
+ @adapter.setpos(y, x)
33
+ max_width = @width - x
34
+ @adapter.addstr(truncate_to_width(text, max_width))
35
+ end
36
+
37
+ def put_with_highlight(y, x, text)
38
+ return if y.negative?
39
+ return if y >= @height || x >= @width
40
+
41
+ @adapter.setpos(y, x)
42
+ max_width = @width - x
43
+ @adapter.with_highlight do
44
+ @adapter.addstr(truncate_to_width(text, max_width))
45
+ end
46
+ end
47
+
48
+ def put_with_style(y, x, text, style)
49
+ return if y.negative?
50
+ return if y >= @height || x >= @width
51
+ return put(y, x, text) unless @color_manager && style
52
+
53
+ @adapter.setpos(y, x)
54
+ max_width = @width - x
55
+ truncated_text = truncate_to_width(text, max_width)
56
+
57
+ pair_index = ensure_color_pair(style[:fg], style[:bg])
58
+ @adapter.with_color(pair_index, bold: style[:bold], underline: style[:underline]) do
59
+ @adapter.addstr(truncated_text)
60
+ end
61
+ end
62
+
63
+ def move_cursor(y, x)
64
+ x = [[x, 0].max, @width - 1].min
65
+ y = [[y, 0].max, @height - 1].min
66
+ @adapter.setpos(y, x)
67
+ end
68
+
69
+ private
70
+
71
+ def ensure_color_pair(fg, bg)
72
+ pair_index = @color_manager.get_pair_index(fg, bg)
73
+ unless @initialized_pairs[pair_index]
74
+ @adapter.init_color_pair(pair_index, fg, bg)
75
+ @initialized_pairs[pair_index] = true
76
+ end
77
+ pair_index
78
+ end
79
+
80
+ def update_size
81
+ @width = @adapter.width
82
+ @height = @adapter.height
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
102
+ end
103
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ # Extracts search completion candidates from buffer content
5
+ class SearchCompleter
6
+ MAX_CANDIDATES = 50
7
+
8
+ def initialize
9
+ @word_cache = {}
10
+ @cache_buffer_id = nil
11
+ @cache_version = nil
12
+ end
13
+
14
+ # Extract words from buffer that match the given prefix
15
+ def complete(buffer, prefix)
16
+ return [] if prefix.nil? || prefix.empty?
17
+
18
+ words = extract_words(buffer)
19
+ matching = words.select { |word| word.start_with?(prefix) && word != prefix }
20
+
21
+ # Sort by length (shorter first) then alphabetically
22
+ matching.sort_by { |w| [w.length, w] }.take(MAX_CANDIDATES)
23
+ end
24
+
25
+ private
26
+
27
+ def extract_words(buffer)
28
+ # Simple cache invalidation based on buffer identity and modification
29
+ buffer_id = buffer.object_id
30
+ version = buffer.lines.hash
31
+
32
+ return @word_cache[buffer_id] if @cache_buffer_id == buffer_id && @cache_version == version
33
+
34
+ words = Set.new
35
+ buffer.line_count.times do |row|
36
+ line = buffer.line(row)
37
+ # Extract words (alphanumeric + underscore, minimum 2 characters)
38
+ line.scan(/\b[a-zA-Z_][a-zA-Z0-9_]+\b/) do |word|
39
+ words.add(word)
40
+ end
41
+ end
42
+
43
+ @cache_buffer_id = buffer_id
44
+ @cache_version = version
45
+ @word_cache[buffer_id] = words.to_a
46
+
47
+ @word_cache[buffer_id]
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ class SearchInput
5
+ attr_reader :buffer, :prompt
6
+
7
+ def initialize(prompt = "/")
8
+ @buffer = ""
9
+ @prompt = prompt
10
+ end
11
+
12
+ def input(char)
13
+ @buffer += char
14
+ end
15
+
16
+ def backspace
17
+ @buffer = @buffer.chop
18
+ end
19
+
20
+ def clear
21
+ @buffer = ""
22
+ end
23
+
24
+ def set_prompt(prompt)
25
+ @prompt = prompt
26
+ end
27
+
28
+ def to_s
29
+ "#{@prompt}#{@buffer}"
30
+ end
31
+
32
+ def pattern
33
+ @buffer
34
+ end
35
+
36
+ def empty?
37
+ @buffer.empty?
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ class SearchState
5
+ attr_reader :pattern, :direction
6
+
7
+ def initialize
8
+ @pattern = nil
9
+ @direction = :forward
10
+ @pattern_version = 0
11
+ @buffer_matches = {} # { buffer_object_id => { version:, matches: [] } }
12
+ end
13
+
14
+ def set_pattern(pattern, direction)
15
+ @pattern = pattern
16
+ @direction = direction
17
+ @pattern_version += 1
18
+ @buffer_matches.clear # Invalidate all cached matches
19
+ end
20
+
21
+ # Calculate matches for a specific buffer (used for n/N navigation)
22
+ def find_all_matches(buffer)
23
+ return [] if @pattern.nil? || @pattern.empty? || buffer.nil?
24
+
25
+ get_or_calculate_matches(buffer)
26
+ end
27
+
28
+ def find_next(current_row, current_col, buffer: nil)
29
+ matches = buffer ? get_or_calculate_matches(buffer) : []
30
+ return nil if matches.empty?
31
+
32
+ # Find next match after current position
33
+ match = matches.find do |m|
34
+ m[:row] > current_row || (m[:row] == current_row && m[:col] > current_col)
35
+ end
36
+
37
+ # Wrap around to beginning if no match found
38
+ match || matches.first
39
+ end
40
+
41
+ def find_previous(current_row, current_col, buffer: nil)
42
+ matches = buffer ? get_or_calculate_matches(buffer) : []
43
+ return nil if matches.empty?
44
+
45
+ # Find previous match before current position
46
+ match = matches.reverse.find do |m|
47
+ m[:row] < current_row || (m[:row] == current_row && m[:col] < current_col)
48
+ end
49
+
50
+ # Wrap around to end if no match found
51
+ match || matches.last
52
+ end
53
+
54
+ def clear
55
+ @pattern = nil
56
+ @pattern_version += 1
57
+ @buffer_matches.clear
58
+ end
59
+
60
+ def has_pattern?
61
+ !@pattern.nil? && !@pattern.empty?
62
+ end
63
+
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
+ end
71
+
72
+ private
73
+
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)
108
+ offset = 0
109
+ while (match_data = line.match(regex, offset))
110
+ col = match_data.begin(0)
111
+ end_col = match_data.end(0) - 1
112
+ matches << { row:, col:, end_col: }
113
+ # Move offset past the end of the match to avoid overlapping matches
114
+ offset = match_data.end(0)
115
+ # Handle zero-length matches to prevent infinite loop
116
+ offset += 1 if match_data[0].empty?
117
+ break if offset >= line.length
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ class Selection
5
+ attr_accessor :start_row, :start_col, :end_row, :end_col
6
+ attr_reader :line_mode
7
+
8
+ def initialize(start_row, start_col, line_mode: false)
9
+ @start_row = start_row
10
+ @start_col = start_col
11
+ @end_row = start_row
12
+ @end_col = start_col
13
+ @line_mode = line_mode
14
+ end
15
+
16
+ def update_end(row, col)
17
+ @end_row = row
18
+ @end_col = col
19
+ end
20
+
21
+ def normalized_range
22
+ if @start_row < @end_row || (@start_row == @end_row && @start_col <= @end_col)
23
+ { start_row: @start_row, start_col: @start_col, end_row: @end_row, end_col: @end_col }
24
+ else
25
+ { start_row: @end_row, start_col: @end_col, end_row: @start_row, end_col: @start_col }
26
+ end
27
+ end
28
+
29
+ def covers_position?(row, col, buffer)
30
+ range = normalized_range
31
+
32
+ if @line_mode
33
+ row.between?(range[:start_row], range[:end_row])
34
+ else
35
+ covers_character_position?(row, col, range, buffer)
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def covers_character_position?(row, col, range, _buffer)
42
+ return false if row < range[:start_row] || row > range[:end_row]
43
+
44
+ if range[:start_row] == range[:end_row]
45
+ col.between?(range[:start_col], range[:end_col])
46
+ elsif row == range[:start_row]
47
+ col >= range[:start_col]
48
+ elsif row == range[:end_row]
49
+ col <= range[:end_col]
50
+ else
51
+ true
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ class StatusLineRenderer
5
+ def initialize(buffer, window, color_scheme)
6
+ @buffer = buffer
7
+ @window = window
8
+ @color_scheme = color_scheme
9
+ end
10
+
11
+ def render(screen, y_position)
12
+ full_status = format_status_line(build_status_text, build_position_text)
13
+
14
+ if @color_scheme
15
+ screen.put_with_style(y_position, @window.x, full_status, @color_scheme[:status_line])
16
+ else
17
+ screen.put(y_position, @window.x, full_status)
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def build_status_text
24
+ status = " #{@buffer.name}"
25
+ status += " [+]" if @buffer.modified
26
+ status
27
+ end
28
+
29
+ def build_position_text
30
+ "#{@window.cursor_row + 1}:#{@window.cursor_col + 1} "
31
+ end
32
+
33
+ def format_status_line(status, position)
34
+ padding = @window.width - status.length - position.length
35
+ padding = 0 if padding.negative?
36
+ full_status = status + (" " * padding) + position
37
+ full_status[0, @window.width]
38
+ end
39
+ end
40
+ end