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
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ module Layout
5
+ class LeafNode < Node
6
+ attr_accessor :window
7
+
8
+ def initialize(window)
9
+ super()
10
+ @window = window
11
+ end
12
+
13
+ def leaf?
14
+ true
15
+ end
16
+
17
+ def windows
18
+ [@window]
19
+ end
20
+
21
+ def find_window_node(target_window)
22
+ @window == target_window ? self : nil
23
+ end
24
+
25
+ def apply_geometry
26
+ @window.x = @x
27
+ @window.y = @y
28
+ @window.width = @width
29
+ @window.height = @height
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ module Layout
5
+ class Node
6
+ attr_accessor :parent, :x, :y, :width, :height
7
+
8
+ def leaf?
9
+ false
10
+ end
11
+
12
+ def split?
13
+ false
14
+ end
15
+
16
+ def windows
17
+ raise Mui::MethodNotOverriddenError, :windows
18
+ end
19
+
20
+ def find_window_node(_window)
21
+ raise Mui::MethodNotOverriddenError, :find_window_node
22
+ end
23
+
24
+ def apply_geometry
25
+ raise Mui::MethodNotOverriddenError, :apply_geometry
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ module Layout
5
+ class SplitNode < Node
6
+ SEPARATOR_SIZE = 1
7
+
8
+ attr_accessor :direction, :children, :ratio
9
+
10
+ def initialize(direction:, children: [], ratio: 0.5)
11
+ super()
12
+ @direction = direction
13
+ @children = children
14
+ @ratio = ratio
15
+ children.each { |c| c.parent = self }
16
+ end
17
+
18
+ def separators
19
+ result = []
20
+ collect_separators(result)
21
+ result
22
+ end
23
+
24
+ def split?
25
+ true
26
+ end
27
+
28
+ def windows
29
+ @children.flat_map(&:windows)
30
+ end
31
+
32
+ def find_window_node(target_window)
33
+ @children.each do |child|
34
+ result = child.find_window_node(target_window)
35
+ return result if result
36
+ end
37
+ nil
38
+ end
39
+
40
+ def apply_geometry
41
+ return if @children.empty?
42
+
43
+ if @children.size == 1
44
+ apply_single_child
45
+ else
46
+ apply_split_children
47
+ end
48
+
49
+ @children.each(&:apply_geometry)
50
+ end
51
+
52
+ def replace_child(old_child, new_child)
53
+ index = @children.index(old_child)
54
+ return unless index
55
+
56
+ @children[index] = new_child
57
+ new_child.parent = self
58
+ end
59
+
60
+ def remove_child(child)
61
+ @children.delete(child)
62
+ end
63
+
64
+ private
65
+
66
+ def apply_single_child
67
+ child = @children.first
68
+ child.x = @x
69
+ child.y = @y
70
+ child.width = @width
71
+ child.height = @height
72
+ end
73
+
74
+ def apply_split_children
75
+ case @direction
76
+ when :horizontal
77
+ apply_horizontal_split
78
+ when :vertical
79
+ apply_vertical_split
80
+ end
81
+ end
82
+
83
+ def apply_horizontal_split
84
+ available_height = @height - SEPARATOR_SIZE
85
+ first_height = (available_height * @ratio).to_i
86
+ second_height = available_height - first_height
87
+
88
+ @children[0].x = @x
89
+ @children[0].y = @y
90
+ @children[0].width = @width
91
+ @children[0].height = first_height
92
+
93
+ @children[1].x = @x
94
+ @children[1].y = @y + first_height + SEPARATOR_SIZE
95
+ @children[1].width = @width
96
+ @children[1].height = second_height
97
+ end
98
+
99
+ def apply_vertical_split
100
+ available_width = @width - SEPARATOR_SIZE
101
+ first_width = (available_width * @ratio).to_i
102
+ second_width = available_width - first_width
103
+
104
+ @children[0].x = @x
105
+ @children[0].y = @y
106
+ @children[0].width = first_width
107
+ @children[0].height = @height
108
+
109
+ @children[1].x = @x + first_width + SEPARATOR_SIZE
110
+ @children[1].y = @y
111
+ @children[1].width = second_width
112
+ @children[1].height = @height
113
+ end
114
+
115
+ def collect_separators(result)
116
+ return if @children.size < 2
117
+
118
+ if @direction == :horizontal
119
+ separator_y = @children[0].y + @children[0].height
120
+ result << { type: :horizontal, x: @x, y: separator_y, length: @width }
121
+ else
122
+ separator_x = @children[0].x + @children[0].width
123
+ result << { type: :vertical, x: separator_x, y: @y, length: @height }
124
+ end
125
+
126
+ @children.each do |child|
127
+ child.send(:collect_separators, result) if child.respond_to?(:collect_separators, true)
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ class LineRenderer
5
+ def initialize(color_scheme)
6
+ @color_scheme = color_scheme
7
+ @highlighters = []
8
+ @resolved_styles = {} # Cache for resolved styles
9
+ end
10
+
11
+ def add_highlighter(highlighter)
12
+ @highlighters << highlighter
13
+ end
14
+
15
+ def render(screen, line, row, x, y, options = {})
16
+ highlights = collect_highlights(row, line, options)
17
+ render_with_highlights(screen, line, x, y, highlights)
18
+ end
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
+
50
+ private
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
+
73
+ def collect_highlights(row, line, options)
74
+ @highlighters
75
+ .flat_map { |h| h.highlights_for(row, line, options) }
76
+ .sort
77
+ end
78
+
79
+ def render_with_highlights(screen, line, x, y, highlights)
80
+ if highlights.empty?
81
+ put_text(screen, y, x, line, :normal)
82
+ return
83
+ end
84
+
85
+ segments = build_segments(line, highlights)
86
+ current_x = x
87
+
88
+ segments.each do |segment|
89
+ put_text(screen, y, current_x, segment[:text], segment[:style])
90
+ current_x += UnicodeWidth.string_width(segment[:text])
91
+ end
92
+ end
93
+
94
+ def build_segments(line, highlights)
95
+ segments = []
96
+ current_pos = 0
97
+ active_highlights = []
98
+
99
+ events = build_events(highlights, line.length)
100
+
101
+ events.each do |event|
102
+ if event[:pos] > current_pos && current_pos < line.length
103
+ end_pos = [event[:pos], line.length].min
104
+ style = active_highlights.max_by(&:priority)&.style || :normal
105
+ text = line[current_pos...end_pos]
106
+ segments << { text:, style: } unless text.empty?
107
+ current_pos = end_pos
108
+ end
109
+
110
+ case event[:type]
111
+ when :start
112
+ active_highlights << event[:highlight]
113
+ when :end
114
+ active_highlights.delete(event[:highlight])
115
+ end
116
+ end
117
+
118
+ if current_pos < line.length
119
+ style = active_highlights.max_by(&:priority)&.style || :normal
120
+ segments << { text: line[current_pos..], style: }
121
+ end
122
+
123
+ segments
124
+ end
125
+
126
+ def build_events(highlights, line_length)
127
+ return [] if highlights.empty?
128
+
129
+ events = []
130
+ highlights.each do |h|
131
+ start_col = [h.start_col, 0].max
132
+ end_col = [h.end_col, line_length - 1].min
133
+ next if start_col > end_col
134
+
135
+ events << [start_col, 1, h] # 1 = start (sorted after end at same position)
136
+ events << [end_col + 1, 0, h] # 0 = end
137
+ end
138
+ # Sort by position, then by type (end before start at same position)
139
+ events.sort!
140
+ # Convert back to hash format
141
+ events.map! { |pos, type, h| { pos:, type: type == 1 ? :start : :end, highlight: h } }
142
+ events
143
+ end
144
+
145
+ def put_text(screen, y, x, text, style)
146
+ return if text.nil? || text.empty?
147
+
148
+ if @color_scheme && @color_scheme[style]
149
+ resolved_style = resolve_style(style)
150
+ screen.put_with_style(y, x, text, resolved_style)
151
+ else
152
+ screen.put(y, x, text)
153
+ end
154
+ end
155
+
156
+ def resolve_style(style)
157
+ # Use cached resolved style if available
158
+ return @resolved_styles[style] if @resolved_styles.key?(style)
159
+
160
+ style_hash = @color_scheme[style]
161
+ resolved = if style_hash[:bg]
162
+ style_hash
163
+ else
164
+ # Inherit background from :normal if not specified
165
+ normal_style = @color_scheme[:normal]
166
+ normal_style ? style_hash.merge(bg: normal_style[:bg]) : style_hash
167
+ end
168
+
169
+ @resolved_styles[style] = resolved
170
+ resolved
171
+ end
172
+ end
173
+ end
data/lib/mui/mode.rb ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ module Mode
5
+ NORMAL = :normal
6
+ INSERT = :insert
7
+ COMMAND = :command
8
+ VISUAL = :visual
9
+ VISUAL_LINE = :visual_line
10
+ SEARCH_FORWARD = :search_forward
11
+ SEARCH_BACKWARD = :search_backward
12
+ end
13
+ end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ # Manages editor mode state and transitions
5
+ class ModeManager
6
+ attr_reader :mode, :selection, :register, :undo_manager, :search_state, :search_input, :editor,
7
+ :last_visual_selection, :key_sequence_handler
8
+
9
+ def initialize(window:, buffer:, command_line:, undo_manager: nil, editor: nil, register: nil, key_sequence_handler: nil)
10
+ @tab_manager = window.is_a?(TabManager) ? window : nil
11
+ @window_manager = window.is_a?(WindowManager) ? window : nil
12
+ @window = !@tab_manager && !@window_manager ? window : nil
13
+ @buffer = buffer
14
+ @command_line = command_line
15
+ @register = register || Mui.register
16
+ @undo_manager = undo_manager
17
+ @editor = editor
18
+ @key_sequence_handler = key_sequence_handler
19
+ @search_state = SearchState.new
20
+ @search_input = SearchInput.new
21
+ @mode = Mode::NORMAL
22
+ @selection = nil
23
+ @visual_handler = nil
24
+ @last_visual_selection = nil
25
+
26
+ initialize_key_handlers
27
+ end
28
+
29
+ def window_manager
30
+ @tab_manager&.window_manager || @window_manager
31
+ end
32
+
33
+ def current_handler
34
+ if visual_mode?
35
+ @visual_handler || @key_handlers[Mode::NORMAL]
36
+ else
37
+ @key_handlers[@mode]
38
+ end
39
+ end
40
+
41
+ def transition(result)
42
+ return unless result.mode
43
+
44
+ clear_visual_mode if result.clear_selection?
45
+
46
+ case result.mode
47
+ when Mode::VISUAL, Mode::VISUAL_LINE
48
+ handle_visual_transition(result)
49
+ when Mode::INSERT
50
+ handle_insert_transition(result)
51
+ when Mode::SEARCH_FORWARD
52
+ handle_search_forward_transition
53
+ when Mode::SEARCH_BACKWARD
54
+ handle_search_backward_transition
55
+ else
56
+ @mode = result.mode
57
+ end
58
+ end
59
+
60
+ def visual_mode?
61
+ @mode == Mode::VISUAL || @mode == Mode::VISUAL_LINE
62
+ end
63
+
64
+ def active_window
65
+ @tab_manager&.active_window || @window_manager&.active_window || @window
66
+ end
67
+
68
+ alias window active_window
69
+
70
+ def restore_visual_selection
71
+ return unless @last_visual_selection
72
+
73
+ line_mode = @last_visual_selection[:line_mode]
74
+ @mode = line_mode ? Mode::VISUAL_LINE : Mode::VISUAL
75
+ @selection = Selection.new(
76
+ @last_visual_selection[:start_row],
77
+ @last_visual_selection[:start_col],
78
+ line_mode:
79
+ )
80
+ @selection.update_end(
81
+ @last_visual_selection[:end_row],
82
+ @last_visual_selection[:end_col]
83
+ )
84
+ @visual_handler = if line_mode
85
+ KeyHandler::VisualLineMode.new(self, @buffer, @selection, @register, undo_manager: @undo_manager)
86
+ else
87
+ KeyHandler::VisualMode.new(self, @buffer, @selection, @register, undo_manager: @undo_manager)
88
+ end
89
+
90
+ # Move cursor to end of selection
91
+ active_window.cursor_row = @last_visual_selection[:end_row]
92
+ active_window.cursor_col = @last_visual_selection[:end_col]
93
+ end
94
+
95
+ private
96
+
97
+ def initialize_key_handlers
98
+ @key_handlers = {
99
+ Mode::NORMAL => KeyHandler::NormalMode.new(self, @buffer, @register, undo_manager: @undo_manager, search_state: @search_state),
100
+ # Use group_started: true to prevent begin_group on initialization
101
+ # The handler will be replaced when actually entering Insert mode
102
+ Mode::INSERT => KeyHandler::InsertMode.new(self, @buffer, undo_manager: @undo_manager, group_started: true),
103
+ Mode::COMMAND => KeyHandler::CommandMode.new(self, @buffer, @command_line),
104
+ Mode::SEARCH_FORWARD => KeyHandler::SearchMode.new(self, @buffer, @search_input, @search_state),
105
+ Mode::SEARCH_BACKWARD => KeyHandler::SearchMode.new(self, @buffer, @search_input, @search_state)
106
+ }
107
+ end
108
+
109
+ def create_insert_handler(group_started: false)
110
+ KeyHandler::InsertMode.new(self, @buffer, undo_manager: @undo_manager, group_started:)
111
+ end
112
+
113
+ def handle_insert_transition(result)
114
+ group_started = result.respond_to?(:group_started?) && result.group_started?
115
+ @key_handlers[Mode::INSERT] = create_insert_handler(group_started:)
116
+ @mode = Mode::INSERT
117
+ end
118
+
119
+ def handle_visual_transition(result)
120
+ if result.start_selection?
121
+ start_visual_mode(result.mode, result.line_mode?)
122
+ elsif result.toggle_line_mode?
123
+ toggle_visual_line_mode(result.mode)
124
+ else
125
+ @mode = result.mode
126
+ end
127
+ end
128
+
129
+ def start_visual_mode(mode, line_mode)
130
+ @mode = mode
131
+ @visual_handler = create_visual_handler(line_mode:)
132
+ end
133
+
134
+ def clear_visual_mode
135
+ save_visual_selection if @selection
136
+ @selection = nil
137
+ @visual_handler = nil
138
+ end
139
+
140
+ def save_visual_selection
141
+ return unless @selection
142
+
143
+ @last_visual_selection = {
144
+ start_row: @selection.start_row,
145
+ start_col: @selection.start_col,
146
+ end_row: @selection.end_row,
147
+ end_col: @selection.end_col,
148
+ line_mode: @selection.line_mode
149
+ }
150
+ end
151
+
152
+ def toggle_visual_line_mode(new_mode)
153
+ return unless @selection
154
+
155
+ new_line_mode = new_mode == Mode::VISUAL_LINE
156
+ @selection = Selection.new(@selection.start_row, @selection.start_col, line_mode: new_line_mode)
157
+ @selection.update_end(active_window.cursor_row, active_window.cursor_col)
158
+ @visual_handler = create_visual_handler(line_mode: new_line_mode)
159
+ @mode = new_mode
160
+ end
161
+
162
+ def create_visual_handler(line_mode:)
163
+ @selection = Selection.new(active_window.cursor_row, active_window.cursor_col, line_mode:)
164
+
165
+ if line_mode
166
+ KeyHandler::VisualLineMode.new(self, @buffer, @selection, @register, undo_manager: @undo_manager)
167
+ else
168
+ KeyHandler::VisualMode.new(self, @buffer, @selection, @register, undo_manager: @undo_manager)
169
+ end
170
+ end
171
+
172
+ def handle_search_forward_transition
173
+ @search_input.clear
174
+ @search_input.set_prompt("/")
175
+ @key_handlers[Mode::SEARCH_FORWARD].start_search
176
+ @mode = Mode::SEARCH_FORWARD
177
+ end
178
+
179
+ def handle_search_backward_transition
180
+ @search_input.clear
181
+ @search_input.set_prompt("?")
182
+ @key_handlers[Mode::SEARCH_BACKWARD].start_search
183
+ @mode = Mode::SEARCH_BACKWARD
184
+ end
185
+ end
186
+ end
data/lib/mui/motion.rb ADDED
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ module Motion
5
+ WORD_CHARS = /[a-zA-Z0-9_]/
6
+
7
+ class << self
8
+ # Basic movements
9
+ def left(_buffer, row, col)
10
+ col.positive? ? { row:, col: col - 1 } : nil
11
+ end
12
+
13
+ def right(buffer, row, col)
14
+ line = buffer.line(row)
15
+ col < line.size - 1 ? { row:, col: col + 1 } : nil
16
+ end
17
+
18
+ def up(_buffer, row, col)
19
+ row.positive? ? { row: row - 1, col: } : nil
20
+ end
21
+
22
+ def down(buffer, row, col)
23
+ row < buffer.line_count - 1 ? { row: row + 1, col: } : nil
24
+ end
25
+
26
+ # Word movements
27
+ def word_forward(buffer, row, col)
28
+ line = buffer.line(row)
29
+
30
+ # Move past current word
31
+ col += 1 while col < line.size && line[col] =~ WORD_CHARS
32
+
33
+ # Skip whitespace
34
+ col += 1 while col < line.size && line[col] =~ /\s/
35
+
36
+ # If at end of line, move to next line
37
+ if col >= line.size && row < buffer.line_count - 1
38
+ next_line = buffer.line(row + 1)
39
+ # Find first non-whitespace character on next line
40
+ next_col = 0
41
+ next_col += 1 while next_col < next_line.size && next_line[next_col] =~ /\s/
42
+ return { row: row + 1, col: next_col }
43
+ end
44
+
45
+ { row:, col: }
46
+ end
47
+
48
+ def word_backward(buffer, row, col)
49
+ buffer.line(row)
50
+
51
+ # If at start of line, go to previous line
52
+ if col.zero? && row.positive?
53
+ prev_line = buffer.line(row - 1)
54
+ return word_backward(buffer, row - 1, prev_line.size)
55
+ end
56
+
57
+ # Move back one position to check previous character
58
+ col -= 1 if col.positive?
59
+ line = buffer.line(row)
60
+
61
+ # Skip whitespace
62
+ col -= 1 while col.positive? && line[col] =~ /\s/
63
+
64
+ # Move to start of word
65
+ col -= 1 while col.positive? && line[col - 1] =~ WORD_CHARS
66
+
67
+ { row:, col: }
68
+ end
69
+
70
+ def word_end(buffer, row, col)
71
+ line = buffer.line(row)
72
+
73
+ # Move forward one position
74
+ col += 1 if col < line.size
75
+
76
+ # Skip whitespace
77
+ col += 1 while col < line.size && line[col] =~ /\s/
78
+
79
+ # If at end of line, move to next line
80
+ return word_end(buffer, row + 1, 0) if col >= line.size && row < buffer.line_count - 1
81
+
82
+ # Move to end of word
83
+ col += 1 while col < line.size - 1 && line[col + 1] =~ WORD_CHARS
84
+
85
+ { row:, col: }
86
+ end
87
+
88
+ # Line start/end movements
89
+ def line_start(_buffer, row, _col)
90
+ { row:, col: 0 }
91
+ end
92
+
93
+ def first_non_blank(buffer, row, _col)
94
+ line = buffer.line(row)
95
+ new_col = line.index(/\S/) || 0
96
+ { row:, col: new_col }
97
+ end
98
+
99
+ def line_end(buffer, row, _col)
100
+ line = buffer.line(row)
101
+ { row:, col: [line.size - 1, 0].max }
102
+ end
103
+
104
+ # File start/end movements
105
+ def file_start(_buffer, _row, _col)
106
+ { row: 0, col: 0 }
107
+ end
108
+
109
+ def file_end(buffer, _row, _col)
110
+ last_row = buffer.line_count - 1
111
+ { row: last_row, col: 0 }
112
+ end
113
+
114
+ # Character search (f, F, t, T)
115
+ def find_char_forward(buffer, row, col, char)
116
+ line = buffer.line(row)
117
+ index = line.index(char, col + 1)
118
+ index ? { row:, col: index } : nil
119
+ end
120
+
121
+ def find_char_backward(buffer, row, col, char)
122
+ line = buffer.line(row)
123
+ search_range = line[0...col]
124
+ index = search_range.rindex(char)
125
+ index ? { row:, col: index } : nil
126
+ end
127
+
128
+ def till_char_forward(buffer, row, col, char)
129
+ result = find_char_forward(buffer, row, col, char)
130
+ result ? { row: result[:row], col: result[:col] - 1 } : nil
131
+ end
132
+
133
+ def till_char_backward(buffer, row, col, char)
134
+ result = find_char_backward(buffer, row, col, char)
135
+ result ? { row: result[:row], col: result[:col] + 1 } : nil
136
+ end
137
+ end
138
+ end
139
+ end