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,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ module Highlighters
5
+ class SearchHighlighter < Base
6
+ def highlights_for(row, _line, options = {})
7
+ search_state = options[:search_state]
8
+ return [] unless search_state&.has_pattern?
9
+
10
+ matches = search_state.matches_for_row(row)
11
+ matches.map do |match|
12
+ Highlight.new(
13
+ start_col: match[:col],
14
+ end_col: match[:end_col],
15
+ style: :search_highlight,
16
+ priority:
17
+ )
18
+ end
19
+ end
20
+
21
+ def priority
22
+ PRIORITY_SEARCH
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ module Highlighters
5
+ class SelectionHighlighter < Base
6
+ def highlights_for(row, line, options = {})
7
+ selection = options[:selection]
8
+ return [] unless selection
9
+
10
+ range = selection.normalized_range
11
+ return [] if row < range[:start_row] || row > range[:end_row]
12
+
13
+ if selection.line_mode
14
+ line_mode_highlights(line)
15
+ else
16
+ char_mode_highlights(row, line, range)
17
+ end
18
+ end
19
+
20
+ def priority
21
+ PRIORITY_SELECTION
22
+ end
23
+
24
+ private
25
+
26
+ def line_mode_highlights(line)
27
+ [Highlight.new(
28
+ start_col: 0,
29
+ end_col: [line.length - 1, 0].max,
30
+ style: :visual_selection,
31
+ priority:
32
+ )]
33
+ end
34
+
35
+ def char_mode_highlights(row, line, range)
36
+ start_col = row == range[:start_row] ? range[:start_col] : 0
37
+ end_col = row == range[:end_row] ? range[:end_col] : [line.length - 1, 0].max
38
+
39
+ [Highlight.new(
40
+ start_col:,
41
+ end_col:,
42
+ style: :visual_selection,
43
+ priority:
44
+ )]
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ module Highlighters
5
+ # Provides syntax highlighting based on language-specific lexers
6
+ class SyntaxHighlighter < Base
7
+ # Maps token types to ColorScheme style names
8
+ # Note: identifier and operator are excluded to reduce highlight count
9
+ # (they typically use the same color as normal text)
10
+ TOKEN_STYLE_MAP = {
11
+ keyword: :syntax_keyword,
12
+ string: :syntax_string,
13
+ comment: :syntax_comment,
14
+ number: :syntax_number,
15
+ symbol: :syntax_symbol,
16
+ constant: :syntax_constant,
17
+ preprocessor: :syntax_preprocessor,
18
+ char: :syntax_string,
19
+ instance_variable: :syntax_instance_variable,
20
+ global_variable: :syntax_global_variable,
21
+ method_call: :syntax_method_call,
22
+ type: :syntax_type
23
+ }.freeze
24
+
25
+ def initialize(color_scheme, buffer: nil)
26
+ super(color_scheme)
27
+ @buffer = buffer
28
+ setup_lexer
29
+ end
30
+
31
+ # Update the buffer and reset the lexer
32
+ def buffer=(new_buffer)
33
+ @buffer = new_buffer
34
+ setup_lexer
35
+ end
36
+
37
+ # Generate highlights for a line
38
+ # TODO: Refactor to reduce complexity (Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity)
39
+ def highlights_for(row, line, options = {})
40
+ return [] unless @lexer
41
+ return [] unless Mui.config.get(:syntax)
42
+
43
+ buffer_lines = options[:buffer]&.lines || @buffer&.lines || []
44
+ tokens = @token_cache.tokens_for(row, line, buffer_lines)
45
+
46
+ tokens.filter_map do |token|
47
+ style = style_for_token_type(token.type)
48
+ next unless style && @color_scheme[style]
49
+
50
+ Highlight.new(
51
+ start_col: token.start_col,
52
+ end_col: token.end_col,
53
+ style:,
54
+ priority:
55
+ )
56
+ end
57
+ end
58
+
59
+ def priority
60
+ PRIORITY_SYNTAX
61
+ end
62
+
63
+ # Invalidate cache from a specific row onwards
64
+ # Called when buffer content changes
65
+ def invalidate_from(row)
66
+ @token_cache&.invalidate(row)
67
+ end
68
+
69
+ # Clear the entire cache
70
+ def clear_cache
71
+ @token_cache&.clear
72
+ end
73
+
74
+ # Prefetch tokens for lines around the visible area
75
+ def prefetch(visible_start, visible_end)
76
+ return unless @lexer && @token_cache && @buffer
77
+ return unless Mui.config.get(:syntax)
78
+
79
+ @token_cache.prefetch(visible_start, visible_end, @buffer.lines)
80
+ end
81
+
82
+ # Check if this highlighter is active (has a lexer)
83
+ def active?
84
+ !@lexer.nil?
85
+ end
86
+
87
+ private
88
+
89
+ def setup_lexer
90
+ @lexer = nil
91
+ @token_cache = nil
92
+ return unless @buffer
93
+
94
+ @lexer = Syntax::LanguageDetector.lexer_for_file(@buffer.name)
95
+ return unless @lexer
96
+
97
+ @token_cache = Syntax::TokenCache.new(@lexer)
98
+ end
99
+
100
+ def style_for_token_type(type)
101
+ TOKEN_STYLE_MAP[type]
102
+ end
103
+ end
104
+ end
105
+ end
data/lib/mui/input.rb ADDED
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ class Input
5
+ def initialize(adapter:)
6
+ @adapter = adapter
7
+ end
8
+
9
+ def read
10
+ @adapter.getch
11
+ end
12
+
13
+ def read_nonblock
14
+ @adapter.getch_nonblock
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ # Renders completion popup for Insert mode (LSP completions)
5
+ class InsertCompletionRenderer
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, cursor_row, cursor_col)
14
+ return unless completion_state.active?
15
+
16
+ items = completion_state.items
17
+ selected_index = completion_state.selected_index
18
+
19
+ # Calculate visible window
20
+ visible_start, visible_end = calculate_visible_range(items.length, selected_index)
21
+ visible_items = items[visible_start...visible_end]
22
+
23
+ # Calculate popup dimensions
24
+ max_width = calculate_max_width(visible_items)
25
+ popup_height = visible_items.length
26
+
27
+ # Calculate position (popup appears below the cursor)
28
+ popup_row = cursor_row + 1
29
+ popup_col = cursor_col
30
+
31
+ # If popup would go below screen, show above cursor instead
32
+ popup_row = cursor_row - popup_height if popup_row + popup_height > @screen.height - 1
33
+
34
+ # Ensure popup stays within screen bounds
35
+ popup_col = [@screen.width - max_width - 1, popup_col].min
36
+ popup_col = [0, popup_col].max
37
+ popup_row = [0, popup_row].max
38
+
39
+ # Render each visible item
40
+ visible_items.each_with_index do |item, i|
41
+ actual_index = visible_start + i
42
+ is_selected = actual_index == selected_index
43
+
44
+ render_item(
45
+ item,
46
+ popup_row + i,
47
+ popup_col,
48
+ max_width,
49
+ is_selected
50
+ )
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def calculate_visible_range(total_count, selected_index)
57
+ return [0, total_count] if total_count <= MAX_VISIBLE_ITEMS
58
+
59
+ # Try to center the selected item
60
+ half = MAX_VISIBLE_ITEMS / 2
61
+ start_index = selected_index - half
62
+ start_index = [0, start_index].max
63
+ end_index = start_index + MAX_VISIBLE_ITEMS
64
+ end_index = [total_count, end_index].min
65
+ start_index = end_index - MAX_VISIBLE_ITEMS
66
+
67
+ [start_index, end_index]
68
+ end
69
+
70
+ def calculate_max_width(items)
71
+ return 0 if items.empty?
72
+
73
+ items.map { |item| display_width(item_label(item)) }.max + 2 # +2 for padding
74
+ end
75
+
76
+ def item_label(item)
77
+ item[:label] || item.to_s
78
+ end
79
+
80
+ def display_width(text)
81
+ UnicodeWidth.string_width(text)
82
+ end
83
+
84
+ def render_item(item, row, col, width, selected)
85
+ style_key = selected ? :completion_popup_selected : :completion_popup
86
+ style = @color_scheme[style_key]
87
+ label = item_label(item)
88
+ padded_text = " #{label}".ljust(width)
89
+ @screen.put_with_style(row, col, padded_text, style)
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ # Manages Insert mode completion state for LSP completions
5
+ class InsertCompletionState
6
+ attr_reader :items, :selected_index, :prefix, :original_items
7
+
8
+ def initialize
9
+ reset
10
+ end
11
+
12
+ def reset
13
+ @items = []
14
+ @original_items = []
15
+ @selected_index = 0
16
+ @prefix = ""
17
+ end
18
+
19
+ def active?
20
+ !@items.empty?
21
+ end
22
+
23
+ def start(items, prefix: "")
24
+ @original_items = items.dup
25
+ @items = items
26
+ @selected_index = 0
27
+ @prefix = prefix
28
+ end
29
+
30
+ # Update prefix and filter items based on new prefix
31
+ def update_prefix(new_prefix)
32
+ return if new_prefix == @prefix
33
+
34
+ @prefix = new_prefix
35
+ @items = @original_items.select do |item|
36
+ label = item[:label] || item[:insert_text] || ""
37
+ label.downcase.start_with?(new_prefix.downcase)
38
+ end
39
+ @selected_index = 0
40
+ end
41
+
42
+ def select_next
43
+ return unless active?
44
+
45
+ @selected_index = (@selected_index + 1) % @items.length
46
+ end
47
+
48
+ def select_previous
49
+ return unless active?
50
+
51
+ @selected_index = (@selected_index - 1) % @items.length
52
+ end
53
+
54
+ def current_item
55
+ return nil unless active?
56
+
57
+ @items[@selected_index]
58
+ end
59
+
60
+ # Returns the text to insert for the current item
61
+ def insert_text
62
+ return nil unless current_item
63
+
64
+ item = current_item
65
+ # Prefer textEdit.newText if available (text_edit value has string keys from LSP)
66
+ item.dig(:text_edit, "newText") || item[:insert_text] || item[:label] || item.to_s
67
+ end
68
+
69
+ # Returns the textEdit range if available
70
+ def text_edit_range
71
+ return nil unless current_item
72
+
73
+ # text_edit value has string keys from LSP
74
+ current_item.dig(:text_edit, "range")
75
+ end
76
+ end
77
+ end
data/lib/mui/job.rb ADDED
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ # Represents a single asynchronous job
5
+ class Job
6
+ # Job status constants
7
+ module Status
8
+ PENDING = :pending
9
+ RUNNING = :running
10
+ COMPLETED = :completed
11
+ FAILED = :failed
12
+ CANCELLED = :cancelled
13
+ end
14
+
15
+ attr_reader :id, :status, :result, :error
16
+
17
+ def initialize(id, &block)
18
+ @id = id
19
+ @block = block
20
+ @status = Status::PENDING
21
+ @result = nil
22
+ @error = nil
23
+ @cancelled = false
24
+ @mutex = Mutex.new
25
+ end
26
+
27
+ def run
28
+ @mutex.synchronize do
29
+ return if @cancelled
30
+
31
+ @status = Status::RUNNING
32
+ end
33
+
34
+ begin
35
+ @result = @block.call
36
+ @mutex.synchronize do
37
+ @status = @cancelled ? Status::CANCELLED : Status::COMPLETED
38
+ end
39
+ rescue StandardError => e
40
+ @mutex.synchronize do
41
+ @error = e
42
+ @status = Status::FAILED
43
+ end
44
+ end
45
+ end
46
+
47
+ def cancel
48
+ @mutex.synchronize do
49
+ return false if @status == Status::COMPLETED || @status == Status::FAILED
50
+
51
+ @cancelled = true
52
+ @status = Status::CANCELLED if @status == Status::PENDING
53
+ true
54
+ end
55
+ end
56
+
57
+ def pending?
58
+ @status == Status::PENDING
59
+ end
60
+
61
+ def running?
62
+ @status == Status::RUNNING
63
+ end
64
+
65
+ def completed?
66
+ @status == Status::COMPLETED
67
+ end
68
+
69
+ def failed?
70
+ @status == Status::FAILED
71
+ end
72
+
73
+ def cancelled?
74
+ @status == Status::CANCELLED
75
+ end
76
+
77
+ def finished?
78
+ completed? || failed? || cancelled?
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Mui
6
+ # Manages asynchronous job execution and result collection
7
+ class JobManager
8
+ def initialize(autocmd: nil)
9
+ @autocmd = autocmd
10
+ @result_queue = Queue.new
11
+ @next_id = 0
12
+ @id_mutex = Mutex.new
13
+ @active_jobs = {}
14
+ @jobs_mutex = Mutex.new
15
+ end
16
+
17
+ def run_async(on_complete: nil, &)
18
+ job = create_job(&)
19
+
20
+ Thread.new do
21
+ job.run
22
+ @result_queue.push({ job:, callback: on_complete })
23
+ end
24
+
25
+ job
26
+ end
27
+
28
+ def run_command(cmd, on_complete: nil)
29
+ run_async(on_complete:) do
30
+ stdout, stderr, status = Open3.capture3(*Array(cmd))
31
+ {
32
+ stdout:,
33
+ stderr:,
34
+ exit_status: status.exitstatus,
35
+ success: status.success?
36
+ }
37
+ end
38
+ end
39
+
40
+ def poll
41
+ processed = []
42
+
43
+ loop do
44
+ entry = @result_queue.pop(true) # non-blocking
45
+ processed << entry
46
+ invoke_callback(entry)
47
+ remove_job(entry[:job].id)
48
+ rescue ThreadError
49
+ break # Queue is empty
50
+ end
51
+
52
+ processed
53
+ end
54
+
55
+ def job(id)
56
+ @jobs_mutex.synchronize { @active_jobs[id] }
57
+ end
58
+
59
+ def cancel(id)
60
+ job = job(id)
61
+ return false unless job
62
+
63
+ job.cancel
64
+ end
65
+
66
+ def active_count
67
+ @jobs_mutex.synchronize { @active_jobs.values.count { |j| !j.finished? } }
68
+ end
69
+
70
+ def busy?
71
+ active_count.positive?
72
+ end
73
+
74
+ private
75
+
76
+ def create_job(&)
77
+ id = generate_id
78
+ job = Job.new(id, &)
79
+ @jobs_mutex.synchronize { @active_jobs[id] = job }
80
+ job
81
+ end
82
+
83
+ def generate_id
84
+ @id_mutex.synchronize do
85
+ @next_id += 1
86
+ end
87
+ end
88
+
89
+ def remove_job(id)
90
+ @jobs_mutex.synchronize { @active_jobs.delete(id) }
91
+ end
92
+
93
+ def invoke_callback(entry)
94
+ entry[:callback]&.call(entry[:job])
95
+ trigger_autocmd_event(entry[:job])
96
+ rescue StandardError => e
97
+ warn "Job callback error: #{e.message}"
98
+ warn e.backtrace.first(5).join("\n")
99
+ end
100
+
101
+ def trigger_autocmd_event(job)
102
+ return unless @autocmd
103
+
104
+ event = case job.status
105
+ when Job::Status::COMPLETED then :JobCompleted
106
+ when Job::Status::FAILED then :JobFailed
107
+ when Job::Status::CANCELLED then :JobCancelled
108
+ end
109
+
110
+ @autocmd.trigger(event, job:) if event
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ # Key code constants for terminal input handling
5
+ module KeyCode
6
+ ESCAPE = 27
7
+ BACKSPACE = 127
8
+ ENTER_CR = 13
9
+ ENTER_LF = 10
10
+ TAB = 9
11
+ PRINTABLE_MIN = 32
12
+ # Extended to support Unicode characters (including CJK)
13
+ # 0x10FFFF is the maximum valid Unicode code point
14
+ PRINTABLE_MAX = 0x10FFFF
15
+
16
+ # Control key codes (Ctrl+letter)
17
+ CTRL_SPACE = 0 # Also Ctrl+@ (NUL)
18
+ CTRL_C = 3
19
+ CTRL_H = 8 # Also backspace in some terminals
20
+ CTRL_J = 10 # Also newline
21
+ CTRL_K = 11
22
+ CTRL_L = 12
23
+ CTRL_N = 14
24
+ CTRL_O = 15
25
+ CTRL_P = 16
26
+ CTRL_S = 19
27
+ CTRL_V = 22
28
+ CTRL_W = 23
29
+ end
30
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../error"
4
+
5
+ module Mui
6
+ module KeyHandler
7
+ class MethodNotOverriddenError < Mui::Error; end
8
+
9
+ # Base class for mode-specific key handlers
10
+ class Base
11
+ attr_accessor :mode_manager
12
+
13
+ def initialize(mode_manager, buffer)
14
+ @mode_manager = mode_manager
15
+ @buffer = buffer
16
+ end
17
+
18
+ def window
19
+ @mode_manager&.active_window
20
+ end
21
+
22
+ def buffer
23
+ window&.buffer || @buffer
24
+ end
25
+
26
+ def editor
27
+ @mode_manager&.editor
28
+ end
29
+
30
+ # Handle a key input
31
+ def handle(_key)
32
+ raise MethodNotOverriddenError, "Subclasses must orverride #handle"
33
+ end
34
+
35
+ private
36
+
37
+ def cursor_row
38
+ window.cursor_row
39
+ end
40
+
41
+ def cursor_col
42
+ window.cursor_col
43
+ end
44
+
45
+ def cursor_row=(value)
46
+ window.cursor_row = value
47
+ end
48
+
49
+ def cursor_col=(value)
50
+ window.cursor_col = value
51
+ end
52
+
53
+ def current_line
54
+ buffer.line(cursor_row)
55
+ end
56
+
57
+ def current_line_length
58
+ current_line.length
59
+ end
60
+
61
+ def extract_printable_char(key)
62
+ if key.is_a?(String)
63
+ # Curses returns multibyte characters as String
64
+ key
65
+ elsif key.is_a?(Integer) && key >= KeyCode::PRINTABLE_MIN && key <= KeyCode::PRINTABLE_MAX
66
+ # Use UTF-8 encoding to support Unicode characters
67
+ key.chr(Encoding::UTF_8)
68
+ end
69
+ rescue RangeError
70
+ # Invalid Unicode code point
71
+ nil
72
+ end
73
+
74
+ def key_to_char(key)
75
+ key.is_a?(String) ? key : key.chr
76
+ rescue RangeError
77
+ nil
78
+ end
79
+
80
+ def execute_pending_motion(char)
81
+ case @pending_motion
82
+ when :g
83
+ char == "g" ? Motion.file_start(buffer, cursor_row, cursor_col) : nil
84
+ when :f
85
+ Motion.find_char_forward(buffer, cursor_row, cursor_col, char)
86
+ when :F
87
+ Motion.find_char_backward(buffer, cursor_row, cursor_col, char)
88
+ when :t
89
+ Motion.till_char_forward(buffer, cursor_row, cursor_col, char)
90
+ when :T
91
+ Motion.till_char_backward(buffer, cursor_row, cursor_col, char)
92
+ end
93
+ end
94
+
95
+ def result(mode: nil, message: nil, quit: false)
96
+ HandlerResult::Base.new(mode:, message:, quit:)
97
+ end
98
+ end
99
+ end
100
+ end