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,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "key_handler/motions/motion_handler"
4
+ require_relative "key_handler/operators/base_operator"
5
+ require_relative "key_handler/operators/delete_operator"
6
+ require_relative "key_handler/operators/change_operator"
7
+ require_relative "key_handler/operators/yank_operator"
8
+ require_relative "key_handler/operators/paste_operator"
9
+ require_relative "key_handler/base"
10
+ require_relative "key_handler/normal_mode"
11
+ require_relative "key_handler/insert_mode"
12
+ require_relative "key_handler/command_mode"
13
+ require_relative "key_handler/visual_mode"
14
+ require_relative "key_handler/visual_line_mode"
15
+ require_relative "key_handler/search_mode"
16
+ require_relative "key_handler/window_command"
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ module Layout
5
+ class Calculator
6
+ def calculate(root, x, y, width, height)
7
+ root.x = x
8
+ root.y = y
9
+ root.width = width
10
+ root.height = height
11
+ root.apply_geometry
12
+ end
13
+ end
14
+ end
15
+ end
@@ -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,122 @@
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
+ private
21
+
22
+ def collect_highlights(row, line, options)
23
+ @highlighters
24
+ .flat_map { |h| h.highlights_for(row, line, options) }
25
+ .sort
26
+ end
27
+
28
+ def render_with_highlights(screen, line, x, y, highlights)
29
+ if highlights.empty?
30
+ put_text(screen, y, x, line, :normal)
31
+ return
32
+ end
33
+
34
+ segments = build_segments(line, highlights)
35
+ current_x = x
36
+
37
+ segments.each do |segment|
38
+ put_text(screen, y, current_x, segment[:text], segment[:style])
39
+ current_x += segment[:text].length
40
+ end
41
+ end
42
+
43
+ def build_segments(line, highlights)
44
+ segments = []
45
+ current_pos = 0
46
+ active_highlights = []
47
+
48
+ events = build_events(highlights, line.length)
49
+
50
+ events.each do |event|
51
+ if event[:pos] > current_pos && current_pos < line.length
52
+ end_pos = [event[:pos], line.length].min
53
+ style = active_highlights.max_by(&:priority)&.style || :normal
54
+ text = line[current_pos...end_pos]
55
+ segments << { text:, style: } unless text.empty?
56
+ current_pos = end_pos
57
+ end
58
+
59
+ case event[:type]
60
+ when :start
61
+ active_highlights << event[:highlight]
62
+ when :end
63
+ active_highlights.delete(event[:highlight])
64
+ end
65
+ end
66
+
67
+ if current_pos < line.length
68
+ style = active_highlights.max_by(&:priority)&.style || :normal
69
+ segments << { text: line[current_pos..], style: }
70
+ end
71
+
72
+ segments
73
+ end
74
+
75
+ def build_events(highlights, line_length)
76
+ return [] if highlights.empty?
77
+
78
+ events = []
79
+ highlights.each do |h|
80
+ start_col = [h.start_col, 0].max
81
+ end_col = [h.end_col, line_length - 1].min
82
+ next if start_col > end_col
83
+
84
+ events << [start_col, 1, h] # 1 = start (sorted after end at same position)
85
+ events << [end_col + 1, 0, h] # 0 = end
86
+ end
87
+ # Sort by position, then by type (end before start at same position)
88
+ events.sort!
89
+ # Convert back to hash format
90
+ events.map! { |pos, type, h| { pos:, type: type == 1 ? :start : :end, highlight: h } }
91
+ events
92
+ end
93
+
94
+ def put_text(screen, y, x, text, style)
95
+ return if text.nil? || text.empty?
96
+
97
+ if @color_scheme && @color_scheme[style]
98
+ resolved_style = resolve_style(style)
99
+ screen.put_with_style(y, x, text, resolved_style)
100
+ else
101
+ screen.put(y, x, text)
102
+ end
103
+ end
104
+
105
+ def resolve_style(style)
106
+ # Use cached resolved style if available
107
+ return @resolved_styles[style] if @resolved_styles.key?(style)
108
+
109
+ style_hash = @color_scheme[style]
110
+ resolved = if style_hash[:bg]
111
+ style_hash
112
+ else
113
+ # Inherit background from :normal if not specified
114
+ normal_style = @color_scheme[:normal]
115
+ normal_style ? style_hash.merge(bg: normal_style[:bg]) : style_hash
116
+ end
117
+
118
+ @resolved_styles[style] = resolved
119
+ resolved
120
+ end
121
+ end
122
+ 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,185 @@
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
8
+
9
+ def initialize(window:, buffer:, command_line:, undo_manager: nil, editor: nil, register: 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
+ @search_state = SearchState.new
19
+ @search_input = SearchInput.new
20
+ @mode = Mode::NORMAL
21
+ @selection = nil
22
+ @visual_handler = nil
23
+ @last_visual_selection = nil
24
+
25
+ initialize_key_handlers
26
+ end
27
+
28
+ def window_manager
29
+ @tab_manager&.window_manager || @window_manager
30
+ end
31
+
32
+ def current_handler
33
+ if visual_mode?
34
+ @visual_handler || @key_handlers[Mode::NORMAL]
35
+ else
36
+ @key_handlers[@mode]
37
+ end
38
+ end
39
+
40
+ def transition(result)
41
+ return unless result.mode
42
+
43
+ clear_visual_mode if result.clear_selection?
44
+
45
+ case result.mode
46
+ when Mode::VISUAL, Mode::VISUAL_LINE
47
+ handle_visual_transition(result)
48
+ when Mode::INSERT
49
+ handle_insert_transition(result)
50
+ when Mode::SEARCH_FORWARD
51
+ handle_search_forward_transition
52
+ when Mode::SEARCH_BACKWARD
53
+ handle_search_backward_transition
54
+ else
55
+ @mode = result.mode
56
+ end
57
+ end
58
+
59
+ def visual_mode?
60
+ @mode == Mode::VISUAL || @mode == Mode::VISUAL_LINE
61
+ end
62
+
63
+ def active_window
64
+ @tab_manager&.active_window || @window_manager&.active_window || @window
65
+ end
66
+
67
+ alias window active_window
68
+
69
+ def restore_visual_selection
70
+ return unless @last_visual_selection
71
+
72
+ line_mode = @last_visual_selection[:line_mode]
73
+ @mode = line_mode ? Mode::VISUAL_LINE : Mode::VISUAL
74
+ @selection = Selection.new(
75
+ @last_visual_selection[:start_row],
76
+ @last_visual_selection[:start_col],
77
+ line_mode:
78
+ )
79
+ @selection.update_end(
80
+ @last_visual_selection[:end_row],
81
+ @last_visual_selection[:end_col]
82
+ )
83
+ @visual_handler = if line_mode
84
+ KeyHandler::VisualLineMode.new(self, @buffer, @selection, @register, undo_manager: @undo_manager)
85
+ else
86
+ KeyHandler::VisualMode.new(self, @buffer, @selection, @register, undo_manager: @undo_manager)
87
+ end
88
+
89
+ # Move cursor to end of selection
90
+ active_window.cursor_row = @last_visual_selection[:end_row]
91
+ active_window.cursor_col = @last_visual_selection[:end_col]
92
+ end
93
+
94
+ private
95
+
96
+ def initialize_key_handlers
97
+ @key_handlers = {
98
+ Mode::NORMAL => KeyHandler::NormalMode.new(self, @buffer, @register, undo_manager: @undo_manager, search_state: @search_state),
99
+ # Use group_started: true to prevent begin_group on initialization
100
+ # The handler will be replaced when actually entering Insert mode
101
+ Mode::INSERT => KeyHandler::InsertMode.new(self, @buffer, undo_manager: @undo_manager, group_started: true),
102
+ Mode::COMMAND => KeyHandler::CommandMode.new(self, @buffer, @command_line),
103
+ Mode::SEARCH_FORWARD => KeyHandler::SearchMode.new(self, @buffer, @search_input, @search_state),
104
+ Mode::SEARCH_BACKWARD => KeyHandler::SearchMode.new(self, @buffer, @search_input, @search_state)
105
+ }
106
+ end
107
+
108
+ def create_insert_handler(group_started: false)
109
+ KeyHandler::InsertMode.new(self, @buffer, undo_manager: @undo_manager, group_started:)
110
+ end
111
+
112
+ def handle_insert_transition(result)
113
+ group_started = result.respond_to?(:group_started?) && result.group_started?
114
+ @key_handlers[Mode::INSERT] = create_insert_handler(group_started:)
115
+ @mode = Mode::INSERT
116
+ end
117
+
118
+ def handle_visual_transition(result)
119
+ if result.start_selection?
120
+ start_visual_mode(result.mode, result.line_mode?)
121
+ elsif result.toggle_line_mode?
122
+ toggle_visual_line_mode(result.mode)
123
+ else
124
+ @mode = result.mode
125
+ end
126
+ end
127
+
128
+ def start_visual_mode(mode, line_mode)
129
+ @mode = mode
130
+ @visual_handler = create_visual_handler(line_mode:)
131
+ end
132
+
133
+ def clear_visual_mode
134
+ save_visual_selection if @selection
135
+ @selection = nil
136
+ @visual_handler = nil
137
+ end
138
+
139
+ def save_visual_selection
140
+ return unless @selection
141
+
142
+ @last_visual_selection = {
143
+ start_row: @selection.start_row,
144
+ start_col: @selection.start_col,
145
+ end_row: @selection.end_row,
146
+ end_col: @selection.end_col,
147
+ line_mode: @selection.line_mode
148
+ }
149
+ end
150
+
151
+ def toggle_visual_line_mode(new_mode)
152
+ return unless @selection
153
+
154
+ new_line_mode = new_mode == Mode::VISUAL_LINE
155
+ @selection = Selection.new(@selection.start_row, @selection.start_col, line_mode: new_line_mode)
156
+ @selection.update_end(active_window.cursor_row, active_window.cursor_col)
157
+ @visual_handler = create_visual_handler(line_mode: new_line_mode)
158
+ @mode = new_mode
159
+ end
160
+
161
+ def create_visual_handler(line_mode:)
162
+ @selection = Selection.new(active_window.cursor_row, active_window.cursor_col, line_mode:)
163
+
164
+ if line_mode
165
+ KeyHandler::VisualLineMode.new(self, @buffer, @selection, @register, undo_manager: @undo_manager)
166
+ else
167
+ KeyHandler::VisualMode.new(self, @buffer, @selection, @register, undo_manager: @undo_manager)
168
+ end
169
+ end
170
+
171
+ def handle_search_forward_transition
172
+ @search_input.clear
173
+ @search_input.set_prompt("/")
174
+ @key_handlers[Mode::SEARCH_FORWARD].start_search
175
+ @mode = Mode::SEARCH_FORWARD
176
+ end
177
+
178
+ def handle_search_backward_transition
179
+ @search_input.clear
180
+ @search_input.set_prompt("?")
181
+ @key_handlers[Mode::SEARCH_BACKWARD].start_search
182
+ @mode = Mode::SEARCH_BACKWARD
183
+ end
184
+ end
185
+ 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