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
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
|
data/lib/mui/register.rb
ADDED
|
@@ -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,85 @@
|
|
|
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_len = @width - x
|
|
34
|
+
@adapter.addstr(text.length > max_len ? text[0, max_len] : text)
|
|
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_len = @width - x
|
|
43
|
+
@adapter.with_highlight do
|
|
44
|
+
@adapter.addstr(text.length > max_len ? text[0, max_len] : text)
|
|
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_len = @width - x
|
|
55
|
+
truncated_text = text.length > max_len ? text[0, max_len] : text
|
|
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
|
+
end
|
|
85
|
+
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,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mui
|
|
4
|
+
class SearchState
|
|
5
|
+
attr_reader :pattern, :direction, :matches
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@pattern = nil
|
|
9
|
+
@direction = :forward
|
|
10
|
+
@matches = []
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def set_pattern(pattern, direction)
|
|
14
|
+
@pattern = pattern
|
|
15
|
+
@direction = direction
|
|
16
|
+
@matches = []
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def find_all_matches(buffer)
|
|
20
|
+
@matches = []
|
|
21
|
+
return if @pattern.nil? || @pattern.empty?
|
|
22
|
+
|
|
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
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def find_next(current_row, current_col)
|
|
36
|
+
return nil if @matches.empty?
|
|
37
|
+
|
|
38
|
+
# Find next match after current position
|
|
39
|
+
match = @matches.find do |m|
|
|
40
|
+
m[:row] > current_row || (m[:row] == current_row && m[:col] > current_col)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Wrap around to beginning if no match found
|
|
44
|
+
match || @matches.first
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def find_previous(current_row, current_col)
|
|
48
|
+
return nil if @matches.empty?
|
|
49
|
+
|
|
50
|
+
# Find previous match before current position
|
|
51
|
+
match = @matches.reverse.find do |m|
|
|
52
|
+
m[:row] < current_row || (m[:row] == current_row && m[:col] < current_col)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Wrap around to end if no match found
|
|
56
|
+
match || @matches.last
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def clear
|
|
60
|
+
@pattern = nil
|
|
61
|
+
@matches = []
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def has_pattern?
|
|
65
|
+
!@pattern.nil? && !@pattern.empty?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def matches_for_row(row)
|
|
69
|
+
@matches.select { |m| m[:row] == row }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def scan_line_matches(line, row, regex)
|
|
75
|
+
offset = 0
|
|
76
|
+
while (match_data = line.match(regex, offset))
|
|
77
|
+
col = match_data.begin(0)
|
|
78
|
+
end_col = match_data.end(0) - 1
|
|
79
|
+
@matches << { row:, col:, end_col: }
|
|
80
|
+
# Move offset past the end of the match to avoid overlapping matches
|
|
81
|
+
offset = match_data.end(0)
|
|
82
|
+
# Handle zero-length matches to prevent infinite loop
|
|
83
|
+
offset += 1 if match_data[0].empty?
|
|
84
|
+
break if offset >= line.length
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
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
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mui
|
|
4
|
+
module Syntax
|
|
5
|
+
# Detects programming language from file path and provides appropriate lexer
|
|
6
|
+
class LanguageDetector
|
|
7
|
+
# Map file extensions to language symbols
|
|
8
|
+
EXTENSION_MAP = {
|
|
9
|
+
".rb" => :ruby,
|
|
10
|
+
".ru" => :ruby,
|
|
11
|
+
".rake" => :ruby,
|
|
12
|
+
".gemspec" => :ruby,
|
|
13
|
+
".c" => :c,
|
|
14
|
+
".h" => :c,
|
|
15
|
+
".y" => :c
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
# Map basenames (files without extension) to language symbols
|
|
19
|
+
BASENAME_MAP = {
|
|
20
|
+
"Gemfile" => :ruby,
|
|
21
|
+
"Rakefile" => :ruby,
|
|
22
|
+
"Guardfile" => :ruby,
|
|
23
|
+
"Vagrantfile" => :ruby,
|
|
24
|
+
"Berksfile" => :ruby,
|
|
25
|
+
"Capfile" => :ruby,
|
|
26
|
+
"Thorfile" => :ruby,
|
|
27
|
+
"Podfile" => :ruby,
|
|
28
|
+
"Brewfile" => :ruby
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
# Detect language from file path
|
|
33
|
+
def detect(file_path)
|
|
34
|
+
return nil if file_path.nil? || file_path.empty?
|
|
35
|
+
|
|
36
|
+
# Try extension first
|
|
37
|
+
ext = File.extname(file_path).downcase
|
|
38
|
+
language = EXTENSION_MAP[ext]
|
|
39
|
+
return language if language
|
|
40
|
+
|
|
41
|
+
# Try basename
|
|
42
|
+
basename = File.basename(file_path)
|
|
43
|
+
BASENAME_MAP[basename]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Get a lexer instance for a language
|
|
47
|
+
def lexer_for(language)
|
|
48
|
+
case language
|
|
49
|
+
when :ruby
|
|
50
|
+
Lexers::RubyLexer.new
|
|
51
|
+
when :c
|
|
52
|
+
Lexers::CLexer.new
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Get a lexer instance for a file path
|
|
57
|
+
def lexer_for_file(file_path)
|
|
58
|
+
language = detect(file_path)
|
|
59
|
+
lexer_for(language)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# List all supported languages
|
|
63
|
+
def supported_languages
|
|
64
|
+
(EXTENSION_MAP.values + BASENAME_MAP.values).uniq
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# List all supported extensions
|
|
68
|
+
def supported_extensions
|
|
69
|
+
EXTENSION_MAP.keys
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|