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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +163 -0
- data/CHANGELOG.md +448 -0
- data/README.md +309 -6
- data/docs/_config.yml +56 -0
- data/docs/configuration.md +301 -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 +149 -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 +30 -0
- data/lib/mui/command_context.rb +90 -0
- data/lib/mui/command_history.rb +89 -0
- data/lib/mui/command_line.rb +167 -0
- data/lib/mui/command_registry.rb +44 -0
- data/lib/mui/completion_renderer.rb +84 -0
- data/lib/mui/completion_state.rb +58 -0
- data/lib/mui/config.rb +58 -0
- data/lib/mui/editor.rb +395 -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 +107 -0
- data/lib/mui/highlight.rb +22 -0
- data/lib/mui/highlighters/base.rb +23 -0
- data/lib/mui/highlighters/search_highlighter.rb +27 -0
- data/lib/mui/highlighters/selection_highlighter.rb +48 -0
- data/lib/mui/highlighters/syntax_highlighter.rb +107 -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 +187 -0
- data/lib/mui/key_handler/command_mode.rb +511 -0
- data/lib/mui/key_handler/insert_mode.rb +323 -0
- data/lib/mui/key_handler/motions/motion_handler.rb +56 -0
- data/lib/mui/key_handler/normal_mode.rb +552 -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 +119 -0
- data/lib/mui/key_handler/operators/yank_operator.rb +127 -0
- data/lib/mui/key_handler/search_mode.rb +191 -0
- data/lib/mui/key_handler/visual_line_mode.rb +20 -0
- data/lib/mui/key_handler/visual_mode.rb +402 -0
- data/lib/mui/key_handler/window_command.rb +112 -0
- data/lib/mui/key_handler.rb +16 -0
- data/lib/mui/key_notation_parser.rb +152 -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/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 +173 -0
- data/lib/mui/mode.rb +13 -0
- data/lib/mui/mode_manager.rb +186 -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 +103 -0
- data/lib/mui/search_completer.rb +50 -0
- data/lib/mui/search_input.rb +40 -0
- data/lib/mui/search_state.rb +121 -0
- data/lib/mui/selection.rb +55 -0
- data/lib/mui/status_line_renderer.rb +40 -0
- data/lib/mui/syntax/language_detector.rb +106 -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/css_lexer.rb +121 -0
- data/lib/mui/syntax/lexers/go_lexer.rb +205 -0
- data/lib/mui/syntax/lexers/html_lexer.rb +118 -0
- data/lib/mui/syntax/lexers/javascript_lexer.rb +197 -0
- data/lib/mui/syntax/lexers/markdown_lexer.rb +210 -0
- data/lib/mui/syntax/lexers/ruby_lexer.rb +114 -0
- data/lib/mui/syntax/lexers/rust_lexer.rb +148 -0
- data/lib/mui/syntax/lexers/typescript_lexer.rb +203 -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 +164 -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 +201 -0
- data/lib/mui/window_manager.rb +256 -0
- data/lib/mui/wrap_cache.rb +40 -0
- data/lib/mui/wrap_helper.rb +84 -0
- data/lib/mui.rb +171 -2
- 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
|
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,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
|