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.
Files changed (86) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +158 -0
  3. data/CHANGELOG.md +349 -0
  4. data/exe/mui +1 -2
  5. data/lib/mui/autocmd.rb +66 -0
  6. data/lib/mui/buffer.rb +275 -0
  7. data/lib/mui/buffer_word_cache.rb +131 -0
  8. data/lib/mui/buffer_word_completer.rb +77 -0
  9. data/lib/mui/color_manager.rb +136 -0
  10. data/lib/mui/color_scheme.rb +63 -0
  11. data/lib/mui/command_completer.rb +21 -0
  12. data/lib/mui/command_context.rb +90 -0
  13. data/lib/mui/command_line.rb +137 -0
  14. data/lib/mui/command_registry.rb +25 -0
  15. data/lib/mui/completion_renderer.rb +84 -0
  16. data/lib/mui/completion_state.rb +58 -0
  17. data/lib/mui/config.rb +56 -0
  18. data/lib/mui/editor.rb +319 -0
  19. data/lib/mui/error.rb +29 -0
  20. data/lib/mui/file_completer.rb +51 -0
  21. data/lib/mui/floating_window.rb +161 -0
  22. data/lib/mui/handler_result.rb +101 -0
  23. data/lib/mui/highlight.rb +22 -0
  24. data/lib/mui/highlighters/base.rb +23 -0
  25. data/lib/mui/highlighters/search_highlighter.rb +26 -0
  26. data/lib/mui/highlighters/selection_highlighter.rb +48 -0
  27. data/lib/mui/highlighters/syntax_highlighter.rb +105 -0
  28. data/lib/mui/input.rb +17 -0
  29. data/lib/mui/insert_completion_renderer.rb +92 -0
  30. data/lib/mui/insert_completion_state.rb +77 -0
  31. data/lib/mui/job.rb +81 -0
  32. data/lib/mui/job_manager.rb +113 -0
  33. data/lib/mui/key_code.rb +30 -0
  34. data/lib/mui/key_handler/base.rb +100 -0
  35. data/lib/mui/key_handler/command_mode.rb +443 -0
  36. data/lib/mui/key_handler/insert_mode.rb +354 -0
  37. data/lib/mui/key_handler/motions/motion_handler.rb +56 -0
  38. data/lib/mui/key_handler/normal_mode.rb +579 -0
  39. data/lib/mui/key_handler/operators/base_operator.rb +134 -0
  40. data/lib/mui/key_handler/operators/change_operator.rb +179 -0
  41. data/lib/mui/key_handler/operators/delete_operator.rb +176 -0
  42. data/lib/mui/key_handler/operators/paste_operator.rb +113 -0
  43. data/lib/mui/key_handler/operators/yank_operator.rb +127 -0
  44. data/lib/mui/key_handler/search_mode.rb +188 -0
  45. data/lib/mui/key_handler/visual_line_mode.rb +20 -0
  46. data/lib/mui/key_handler/visual_mode.rb +397 -0
  47. data/lib/mui/key_handler/window_command.rb +112 -0
  48. data/lib/mui/key_handler.rb +16 -0
  49. data/lib/mui/layout/calculator.rb +15 -0
  50. data/lib/mui/layout/leaf_node.rb +33 -0
  51. data/lib/mui/layout/node.rb +29 -0
  52. data/lib/mui/layout/split_node.rb +132 -0
  53. data/lib/mui/line_renderer.rb +122 -0
  54. data/lib/mui/mode.rb +13 -0
  55. data/lib/mui/mode_manager.rb +185 -0
  56. data/lib/mui/motion.rb +139 -0
  57. data/lib/mui/plugin.rb +35 -0
  58. data/lib/mui/plugin_manager.rb +106 -0
  59. data/lib/mui/register.rb +110 -0
  60. data/lib/mui/screen.rb +85 -0
  61. data/lib/mui/search_completer.rb +50 -0
  62. data/lib/mui/search_input.rb +40 -0
  63. data/lib/mui/search_state.rb +88 -0
  64. data/lib/mui/selection.rb +55 -0
  65. data/lib/mui/status_line_renderer.rb +40 -0
  66. data/lib/mui/syntax/language_detector.rb +74 -0
  67. data/lib/mui/syntax/lexer_base.rb +106 -0
  68. data/lib/mui/syntax/lexers/c_lexer.rb +127 -0
  69. data/lib/mui/syntax/lexers/ruby_lexer.rb +114 -0
  70. data/lib/mui/syntax/token.rb +42 -0
  71. data/lib/mui/syntax/token_cache.rb +91 -0
  72. data/lib/mui/tab_bar_renderer.rb +87 -0
  73. data/lib/mui/tab_manager.rb +96 -0
  74. data/lib/mui/tab_page.rb +35 -0
  75. data/lib/mui/terminal_adapter/base.rb +92 -0
  76. data/lib/mui/terminal_adapter/curses.rb +162 -0
  77. data/lib/mui/terminal_adapter.rb +4 -0
  78. data/lib/mui/themes/default.rb +315 -0
  79. data/lib/mui/undo_manager.rb +83 -0
  80. data/lib/mui/undoable_action.rb +175 -0
  81. data/lib/mui/unicode_width.rb +100 -0
  82. data/lib/mui/version.rb +1 -1
  83. data/lib/mui/window.rb +158 -0
  84. data/lib/mui/window_manager.rb +249 -0
  85. data/lib/mui.rb +156 -2
  86. metadata +98 -3
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ # Provides plugin access to editor internals
5
+ class CommandContext
6
+ attr_reader :buffer, :window, :editor
7
+
8
+ def initialize(editor:, buffer:, window:)
9
+ @editor = editor
10
+ @buffer = buffer
11
+ @window = window
12
+ end
13
+
14
+ def cursor
15
+ { line: @buffer.cursor_y, col: @buffer.cursor_x }
16
+ end
17
+
18
+ def current_line
19
+ @buffer.current_line
20
+ end
21
+
22
+ def insert(text)
23
+ @buffer.insert_text(text)
24
+ end
25
+
26
+ def set_message(msg)
27
+ @editor.message = msg
28
+ end
29
+
30
+ def quit
31
+ @editor.running = false
32
+ end
33
+
34
+ def run_command(name, *)
35
+ @editor.command_registry.execute(name, self, *)
36
+ end
37
+
38
+ def run_async(on_complete: nil, &)
39
+ @editor.job_manager.run_async(on_complete:, &)
40
+ end
41
+
42
+ def run_shell_command(cmd, on_complete: nil)
43
+ @editor.job_manager.run_command(cmd, on_complete:)
44
+ end
45
+
46
+ def jobs_running?
47
+ @editor.job_manager.busy?
48
+ end
49
+
50
+ def cancel_job(id)
51
+ @editor.job_manager.cancel(id)
52
+ end
53
+
54
+ def open_scratch_buffer(name, content)
55
+ @editor.open_scratch_buffer(name, content)
56
+ end
57
+
58
+ # Run an interactive command that needs terminal access (e.g., fzf)
59
+ # Suspends Curses UI, runs command, resumes UI
60
+ def run_interactive_command(cmd)
61
+ require "tempfile"
62
+
63
+ @editor.suspend_ui do
64
+ output_file = Tempfile.new("mui_interactive")
65
+ begin
66
+ # Use shell redirection to capture output while keeping stdin/stderr connected to terminal
67
+ # rubocop:disable Style/SpecialGlobalVars
68
+ success = system("#{cmd} > #{output_file.path}")
69
+ status = $?
70
+ # rubocop:enable Style/SpecialGlobalVars
71
+ exit_status = status&.exitstatus || 1
72
+ {
73
+ stdout: File.read(output_file.path),
74
+ stderr: "",
75
+ exit_status:,
76
+ success: success == true && exit_status.zero?
77
+ }
78
+ ensure
79
+ output_file.close
80
+ output_file.unlink
81
+ end
82
+ end
83
+ end
84
+
85
+ # Check if a command exists in PATH
86
+ def command_exists?(cmd)
87
+ system("which #{cmd} > /dev/null 2>&1")
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ class CommandLine
5
+ # Commands that accept file path arguments
6
+ FILE_COMMANDS = %w[e w sp split vs vsplit tabnew tabe tabedit].freeze
7
+
8
+ attr_reader :buffer, :cursor_pos
9
+
10
+ def initialize
11
+ @buffer = ""
12
+ @cursor_pos = 0
13
+ end
14
+
15
+ def input(char)
16
+ @buffer = @buffer[0...@cursor_pos].to_s + char + @buffer[@cursor_pos..].to_s
17
+ @cursor_pos += char.length
18
+ end
19
+
20
+ def backspace
21
+ return if @cursor_pos.zero?
22
+
23
+ @buffer = @buffer[0...(@cursor_pos - 1)].to_s + @buffer[@cursor_pos..].to_s
24
+ @cursor_pos -= 1
25
+ end
26
+
27
+ def clear
28
+ @buffer = ""
29
+ @cursor_pos = 0
30
+ end
31
+
32
+ def move_cursor_left
33
+ @cursor_pos -= 1 if @cursor_pos.positive?
34
+ end
35
+
36
+ def move_cursor_right
37
+ @cursor_pos += 1 if @cursor_pos < @buffer.length
38
+ end
39
+
40
+ def execute
41
+ result = parse(@buffer)
42
+ @buffer = ""
43
+ @cursor_pos = 0
44
+ result
45
+ end
46
+
47
+ def to_s
48
+ ":#{@buffer}"
49
+ end
50
+
51
+ # Determine completion context based on current buffer
52
+ def completion_context
53
+ # Check if buffer contains a space (command + argument)
54
+ if @buffer.include?(" ")
55
+ # Command with argument
56
+ parts = @buffer.split(/\s+/, 2)
57
+ command = parts[0]
58
+ arg = parts[1] || ""
59
+
60
+ return { type: :file, command:, prefix: arg } if FILE_COMMANDS.include?(command)
61
+
62
+ # Return nil for commands that don't support file completion
63
+ return nil
64
+ end
65
+
66
+ # No space -> command completion
67
+ { type: :command, prefix: @buffer.strip }
68
+ end
69
+
70
+ # Apply completion result to buffer
71
+ def apply_completion(text, context)
72
+ @buffer = if context[:type] == :command
73
+ text
74
+ else
75
+ "#{context[:command]} #{text}"
76
+ end
77
+ @cursor_pos = @buffer.length
78
+ end
79
+
80
+ private
81
+
82
+ def parse(cmd)
83
+ case cmd.strip
84
+ when ""
85
+ { action: :no_op }
86
+ when "e"
87
+ { action: :open }
88
+ when /^e\s+(.+)/
89
+ { action: :open_as, path: ::Regexp.last_match(1) }
90
+ when "w"
91
+ { action: :write }
92
+ when "q"
93
+ { action: :quit }
94
+ when "wq"
95
+ { action: :write_quit }
96
+ when "q!"
97
+ { action: :force_quit }
98
+ when /^w\s+(.+)/
99
+ { action: :write_as, path: ::Regexp.last_match(1) }
100
+ when "sp", "split"
101
+ { action: :split_horizontal }
102
+ when /^sp\s+(.+)/, /^split\s+(.+)/
103
+ { action: :split_horizontal, path: ::Regexp.last_match(1) }
104
+ when "vs", "vsplit"
105
+ { action: :split_vertical }
106
+ when /^vs\s+(.+)/, /^vsplit\s+(.+)/
107
+ { action: :split_vertical, path: ::Regexp.last_match(1) }
108
+ when "close"
109
+ { action: :close_window }
110
+ when "only"
111
+ { action: :only_window }
112
+ when "tabnew", "tabe", "tabedit"
113
+ { action: :tab_new }
114
+ when /^tabnew\s+(.+)/, /^tabe\s+(.+)/, /^tabedit\s+(.+)/
115
+ { action: :tab_new, path: ::Regexp.last_match(1) }
116
+ when "tabclose", "tabc"
117
+ { action: :tab_close }
118
+ when "tabnext", "tabn"
119
+ { action: :tab_next }
120
+ when "tabprev", "tabp", "tabprevious"
121
+ { action: :tab_prev }
122
+ when "tabfirst", "tabf", "tabrewind", "tabr"
123
+ { action: :tab_first }
124
+ when "tablast", "tabl"
125
+ { action: :tab_last }
126
+ when /^tabmove\s+(\d+)/, /^tabm\s+(\d+)/
127
+ { action: :tab_move, position: ::Regexp.last_match(1).to_i }
128
+ when /^(\d+)tabn(?:ext)?/, /^tabn(?:ext)?\s+(\d+)/
129
+ { action: :tab_go, index: ::Regexp.last_match(1).to_i - 1 }
130
+ when /^(\d+)$/
131
+ { action: :goto_line, line_number: ::Regexp.last_match(1).to_i }
132
+ else
133
+ { action: :unknown, command: cmd }
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ # Registry for Ex commands
5
+ class CommandRegistry
6
+ def initialize
7
+ @commands = {}
8
+ end
9
+
10
+ def register(name, &block)
11
+ @commands[name.to_sym] = block
12
+ end
13
+
14
+ def execute(name, context, *)
15
+ command = @commands[name.to_sym]
16
+ raise UnknownCommandError, name unless command
17
+
18
+ command.call(context, *)
19
+ end
20
+
21
+ def exists?(name)
22
+ @commands.key?(name.to_sym)
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ # Renders completion popup menu
5
+ class CompletionRenderer
6
+ MAX_VISIBLE_ITEMS = 10
7
+
8
+ def initialize(screen, color_scheme)
9
+ @screen = screen
10
+ @color_scheme = color_scheme
11
+ end
12
+
13
+ def render(completion_state, base_row, base_col)
14
+ return unless completion_state.active?
15
+
16
+ candidates = completion_state.candidates
17
+ selected_index = completion_state.selected_index
18
+
19
+ # Calculate visible window
20
+ visible_start, visible_end = calculate_visible_range(candidates.length, selected_index)
21
+ visible_candidates = candidates[visible_start...visible_end]
22
+
23
+ # Calculate popup dimensions
24
+ max_width = calculate_max_width(visible_candidates)
25
+ popup_height = visible_candidates.length
26
+
27
+ # Calculate position (popup appears above the command line)
28
+ popup_row = base_row - popup_height
29
+ popup_col = base_col
30
+
31
+ # Ensure popup stays within screen bounds
32
+ popup_col = [@screen.width - max_width - 1, popup_col].min
33
+ popup_col = [0, popup_col].max
34
+ popup_row = [0, popup_row].max
35
+
36
+ # Render each visible candidate
37
+ visible_candidates.each_with_index do |candidate, i|
38
+ actual_index = visible_start + i
39
+ is_selected = actual_index == selected_index
40
+
41
+ render_item(
42
+ candidate,
43
+ popup_row + i,
44
+ popup_col,
45
+ max_width,
46
+ is_selected
47
+ )
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def calculate_visible_range(total_count, selected_index)
54
+ return [0, total_count] if total_count <= MAX_VISIBLE_ITEMS
55
+
56
+ # Try to center the selected item
57
+ half = MAX_VISIBLE_ITEMS / 2
58
+ start_index = selected_index - half
59
+ start_index = [0, start_index].max
60
+ end_index = start_index + MAX_VISIBLE_ITEMS
61
+ end_index = [total_count, end_index].min
62
+ start_index = end_index - MAX_VISIBLE_ITEMS
63
+
64
+ [start_index, end_index]
65
+ end
66
+
67
+ def calculate_max_width(candidates)
68
+ return 0 if candidates.empty?
69
+
70
+ candidates.map { |c| display_width(c) }.max + 2 # +2 for padding
71
+ end
72
+
73
+ def display_width(text)
74
+ UnicodeWidth.string_width(text)
75
+ end
76
+
77
+ def render_item(text, row, col, width, selected)
78
+ style_key = selected ? :completion_popup_selected : :completion_popup
79
+ style = @color_scheme[style_key]
80
+ padded_text = " #{text}".ljust(width)
81
+ @screen.put_with_style(row, col, padded_text, style)
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ # Manages completion popup state
5
+ class CompletionState
6
+ attr_reader :candidates, :selected_index, :original_input, :completion_type
7
+
8
+ def initialize
9
+ reset
10
+ end
11
+
12
+ def reset
13
+ @candidates = []
14
+ @selected_index = 0
15
+ @original_input = nil
16
+ @completion_type = nil # :command or :file
17
+ @confirmed = false
18
+ end
19
+
20
+ def active?
21
+ !@candidates.empty?
22
+ end
23
+
24
+ def start(candidates, original_input, type)
25
+ @candidates = candidates
26
+ @selected_index = 0
27
+ @original_input = original_input
28
+ @completion_type = type
29
+ @confirmed = false
30
+ end
31
+
32
+ def confirm
33
+ @confirmed = true
34
+ end
35
+
36
+ def confirmed?
37
+ @confirmed
38
+ end
39
+
40
+ def select_next
41
+ return unless active?
42
+
43
+ @selected_index = (@selected_index + 1) % @candidates.length
44
+ end
45
+
46
+ def select_previous
47
+ return unless active?
48
+
49
+ @selected_index = (@selected_index - 1) % @candidates.length
50
+ end
51
+
52
+ def current_candidate
53
+ return nil unless active?
54
+
55
+ @candidates[@selected_index]
56
+ end
57
+ end
58
+ end
data/lib/mui/config.rb ADDED
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ class Config
5
+ attr_reader :options, :plugins, :keymaps, :commands, :autocmds
6
+
7
+ def initialize
8
+ @options = {
9
+ colorscheme: "mui",
10
+ syntax: true, # Enable/disable syntax highlighting
11
+ shiftwidth: 2, # Indent width for > and < commands
12
+ expandtab: true, # Use spaces instead of tabs
13
+ tabstop: 8, # Tab display width
14
+ reselect_after_indent: false # Keep selection after > / < in visual mode
15
+ }
16
+ @plugins = []
17
+ @keymaps = {}
18
+ @commands = {}
19
+ @autocmds = {}
20
+ end
21
+
22
+ def set(key, value)
23
+ @options[key.to_sym] = value
24
+ end
25
+
26
+ def get(key)
27
+ @options[key.to_sym]
28
+ end
29
+
30
+ def load_file(path)
31
+ return unless File.exist?(path)
32
+
33
+ instance_eval(File.read(path), path)
34
+ end
35
+
36
+ # Stub for future plugin support
37
+ def use_plugin(gem_name, version = nil)
38
+ @plugins << { gem: gem_name, version: }
39
+ end
40
+
41
+ # Stub for future keymap support
42
+ def add_keymap(mode, key, block)
43
+ @keymaps[mode] ||= {}
44
+ @keymaps[mode][key] = block
45
+ end
46
+
47
+ def add_command(name, block)
48
+ @commands[name.to_sym] = block
49
+ end
50
+
51
+ def add_autocmd(event, pattern, block)
52
+ @autocmds[event.to_sym] ||= []
53
+ @autocmds[event.to_sym] << { pattern:, handler: block }
54
+ end
55
+ end
56
+ end