rufio 1.1.0 → 1.4.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 (80) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -0
  3. data/README.md +26 -297
  4. data/README_ja.md +24 -261
  5. data/Rakefile +14 -1
  6. data/bin/rufio +31 -2
  7. data/docs/fast_zip_design_ja.md +167 -0
  8. data/docs/guide.md +410 -0
  9. data/docs/guide_ja.md +410 -0
  10. data/info/help.md +54 -17
  11. data/info/keybindings.md +50 -22
  12. data/info/welcome.md +14 -5
  13. data/lib/rufio/application.rb +12 -1
  14. data/lib/rufio/archive_controller.rb +211 -0
  15. data/lib/rufio/background_command_executor.rb +9 -0
  16. data/lib/rufio/background_status.rb +49 -0
  17. data/lib/rufio/bookmark_controller.rb +38 -121
  18. data/lib/rufio/bookmark_manager.rb +77 -331
  19. data/lib/rufio/command_completion.rb +28 -2
  20. data/lib/rufio/command_history.rb +5 -2
  21. data/lib/rufio/command_mode.rb +60 -134
  22. data/lib/rufio/command_mode_ui.rb +4 -42
  23. data/lib/rufio/config.rb +88 -136
  24. data/lib/rufio/config_loader.rb +4 -11
  25. data/lib/rufio/dialog_renderer.rb +90 -152
  26. data/lib/rufio/dialog_support.rb +69 -0
  27. data/lib/rufio/document_text_extractor.rb +167 -0
  28. data/lib/rufio/file_operation_controller.rb +49 -222
  29. data/lib/rufio/file_operations.rb +28 -6
  30. data/lib/rufio/file_preview.rb +46 -2
  31. data/lib/rufio/global_search.rb +125 -13
  32. data/lib/rufio/help_menu.rb +19 -7
  33. data/lib/rufio/interpreter_resolver.rb +6 -11
  34. data/lib/rufio/job_manager.rb +2 -5
  35. data/lib/rufio/key_input.rb +77 -0
  36. data/lib/rufio/keybind_handler.rb +83 -116
  37. data/lib/rufio/local_script_scanner.rb +6 -18
  38. data/lib/rufio/native/aarch64-linux-gnu/librufio_zig.so +0 -0
  39. data/lib/rufio/native/aarch64-linux-musl/librufio_zig.so +0 -0
  40. data/lib/rufio/native/aarch64-macos/librufio_zig.dylib +0 -0
  41. data/lib/rufio/native/aarch64-windows/rufio_zig.dll +0 -0
  42. data/lib/rufio/native/x86_64-linux-gnu/librufio_zig.so +0 -0
  43. data/lib/rufio/native/x86_64-linux-musl/librufio_zig.so +0 -0
  44. data/lib/rufio/native/x86_64-macos/librufio_zig.dylib +0 -0
  45. data/lib/rufio/native/x86_64-windows/rufio_zig.dll +0 -0
  46. data/lib/rufio/native_content_search.rb +153 -0
  47. data/lib/rufio/native_scanner.rb +13 -68
  48. data/lib/rufio/native_scanner_zig.rb +106 -56
  49. data/lib/rufio/native_zip.rb +121 -0
  50. data/lib/rufio/navigation_controller.rb +20 -26
  51. data/lib/rufio/path_jump.rb +55 -0
  52. data/lib/rufio/screen.rb +2 -12
  53. data/lib/rufio/script_executor.rb +6 -73
  54. data/lib/rufio/script_file.rb +44 -0
  55. data/lib/rufio/script_path_manager.rb +9 -43
  56. data/lib/rufio/script_runner.rb +13 -50
  57. data/lib/rufio/search_controller.rb +1 -1
  58. data/lib/rufio/shell_command_completion.rb +1 -5
  59. data/lib/rufio/shell_init.rb +90 -0
  60. data/lib/rufio/task_status.rb +5 -1
  61. data/lib/rufio/terminal_ui.rb +84 -298
  62. data/lib/rufio/text_utils.rb +26 -3
  63. data/lib/rufio/ui_renderer.rb +73 -45
  64. data/lib/rufio/version.rb +1 -1
  65. data/lib/rufio/zip_archive.rb +314 -0
  66. data/lib/rufio/zip_extractor.rb +346 -0
  67. data/lib/rufio/zip_writer.rb +268 -0
  68. data/lib/rufio/zoxide_integration.rb +5 -43
  69. data/lib/rufio.rb +6 -0
  70. data/lib_zig/rufio_native/Makefile +54 -17
  71. data/lib_zig/rufio_native/bench.sh +36 -0
  72. data/lib_zig/rufio_native/build.zig +32 -35
  73. data/lib_zig/rufio_native/src/cli.zig +99 -0
  74. data/lib_zig/rufio_native/src/main.zig +187 -83
  75. data/lib_zig/rufio_native/src/os.zig +889 -0
  76. data/lib_zig/rufio_native/src/search.zig +919 -0
  77. data/lib_zig/rufio_native/src/search_test.zig +88 -0
  78. data/lib_zig/rufio_native/src/zip.zig +482 -0
  79. metadata +128 -3
  80. data/lib/rufio/native/rufio_zig.bundle +0 -0
@@ -3,19 +3,36 @@
3
3
  require 'thread'
4
4
  require_relative 'text_utils'
5
5
  require_relative 'config_loader'
6
+ require_relative 'document_text_extractor'
7
+ require_relative 'native_content_search'
6
8
 
7
9
  module Rufio
8
10
  # ブックマーク配下のファイル名・テキスト内容をバックグラウンドで横断検索する。
9
11
  class GlobalSearch
10
12
  SPINNER_FRAMES = ['|', '/', '-', '\\'].freeze
11
13
  SPINNER_INTERVAL = 0.12
14
+ # 検索語に一致した部分の表示(黒の太字・黄色背景)
15
+ MATCH_FG = "\e[30m\e[1m"
16
+ MATCH_BG = "\e[43m"
12
17
 
13
18
  attr_reader :query, :results, :errors, :selected_index, :search_type, :skipped_files
14
19
 
20
+ class << self
21
+ # nil: 利用可能なら Zig 実装を使う(RUFIO_NATIVE_SEARCH=0 で無効化)
22
+ attr_writer :native_search
23
+
24
+ def native_search?
25
+ enabled = @native_search.nil? ? ENV['RUFIO_NATIVE_SEARCH'] != '0' : @native_search
26
+ enabled && NativeContentSearch.available?
27
+ end
28
+ end
29
+
15
30
  def initialize
16
31
  @active = false
17
32
  @scanning = false
18
33
  @worker = nil
34
+ @document_extractor = DocumentTextExtractor.new
35
+ @document_cache = DocumentTextCache.new(extractor: @document_extractor)
19
36
  end
20
37
 
21
38
  def active?
@@ -63,8 +80,12 @@ module Rufio
63
80
  when :entry
64
81
  @entries << value
65
82
  @results << value if @search_type == :content || matches_query?(value)
83
+ when :entries
84
+ # Zig 実装の結果はまとめて届くので、1 フレーム 256 件の上限に縛られない
85
+ @entries.concat(value)
86
+ @results.concat(value)
66
87
  when :error then @errors << value
67
- when :skipped then @skipped_files += 1
88
+ when :skipped then @skipped_files += value || 1
68
89
  when :done then @scanning = false
69
90
  end
70
91
  end
@@ -85,11 +106,11 @@ module Rufio
85
106
  when "\t"
86
107
  @search_type = @search_type == :filename ? :content : :filename
87
108
  start_scan
88
- when "\r", "\n" then return @results[@selected_index]
109
+ when "\r" then return @results[@selected_index]
89
110
  when :down then @selected_index = [@selected_index + 1, [@results.length - 1, 0].max].min
90
111
  when :up then @selected_index = [@selected_index - 1, 0].max
91
- when "\x0e" then handle_key(:down) # Ctrl+N
92
- when "\x10" then handle_key(:up) # Ctrl+P
112
+ when "\x0a", "\x0e" then handle_key(:down) # Ctrl+J / Ctrl+N
113
+ when "\x0b", "\x10" then handle_key(:up) # Ctrl+K / Ctrl+P
93
114
  when "\x15" then update_query('') # Ctrl+U
94
115
  when "\u007f", "\b" then update_query(@query[0...-1])
95
116
  else
@@ -118,11 +139,20 @@ module Rufio
118
139
  first = [@selected_index - capacity + 1, 0].max
119
140
  @results.slice(first, capacity).to_a.each_with_index do |entry, index|
120
141
  selected = first + index == @selected_index
121
- text = "#{selected ? '>' : ' '} [#{entry[:bookmark]}] #{entry[:relative_path]}"
122
- text += ":#{entry[:line_number]}" if entry[:line_number]
142
+ prefix = "#{selected ? '>' : ' '} [#{entry[:bookmark]}] "
143
+ text = "#{prefix}#{entry[:relative_path]}"
144
+ text += if entry[:location]
145
+ " - #{entry[:location]}"
146
+ elsif entry[:line_number]
147
+ ":#{entry[:line_number]}"
148
+ else
149
+ ''
150
+ end
123
151
  y = index * row_height + 3
124
- write_line(screen, y, text, width, selected ? "\e[1;36m" : nil)
125
- write_line(screen, y + 1, " #{entry[:match_text]}", width) if entry[:line_number]
152
+ # ファイル名検索はパス、本文検索はスニペットの一致箇所をハイライトする
153
+ path_highlight = @search_type == :filename ? prefix.length : nil
154
+ write_line(screen, y, text, width, selected ? "\e[1;36m" : nil, highlight_from: path_highlight)
155
+ write_line(screen, y + 1, " #{entry[:match_text]}", width, highlight_from: 4) if entry[:match_text]
126
156
  end
127
157
  if @results.empty?
128
158
  message = if @bookmark_count.zero?
@@ -139,7 +169,7 @@ module Rufio
139
169
  write_line(screen, 3, message, width)
140
170
  end
141
171
  write_line(screen, screen.height - 3, @results[@selected_index]&.[](:path) || @errors.first.to_s, width, "\e[90m")
142
- write_line(screen, screen.height - 2, 'ESC: Back | Enter: Go to file | Up/Down: Select | Type: Search', width)
172
+ write_line(screen, screen.height - 2, 'ESC: Back | Enter: Go to file | Up/Down or Ctrl+J/K: Select | Type: Search', width)
143
173
  end
144
174
 
145
175
  private
@@ -160,9 +190,14 @@ module Rufio
160
190
  return unless @scanning
161
191
 
162
192
  roots, events, type, query = @roots, @events, @search_type, @query.downcase
193
+ native_cache = self.class.native_search? ? (@native_cache ||= NativeContentSearch::Cache.new) : nil
163
194
  @worker = Thread.new do
164
195
  begin
165
- scan_roots(roots, events, type, query)
196
+ if native_cache && type == :content && NativeContentSearch.supports_query?(query)
197
+ scan_roots_natively(roots, events, query, native_cache)
198
+ else
199
+ scan_roots(roots, events, type, query)
200
+ end
166
201
  rescue StandardError => e
167
202
  events << [:error, e.message]
168
203
  end
@@ -230,7 +265,58 @@ module Rufio
230
265
  end
231
266
  end
232
267
 
268
+ # Zig 実装で走査・検索し、PDF などのドキュメントだけ Ruby 側で抽出する
269
+ def scan_roots_natively(roots, events, query, cache)
270
+ valid_roots = []
271
+ roots.each do |name, root|
272
+ root = File.expand_path(root)
273
+ raise Errno::ENOTDIR, root unless File.directory?(root)
274
+
275
+ valid_roots << [name, root]
276
+ rescue SystemCallError, IOError => e
277
+ events << [:error, "#{name}: #{e.message}"]
278
+ end
279
+ return if valid_roots.empty?
280
+
281
+ engine = NativeContentSearch.new(roots: valid_roots.map(&:last), query: query,
282
+ excluded_extensions: @excluded_extensions,
283
+ document_extensions: DocumentTextExtractor::EXTENSIONS, cache: cache)
284
+ engine.each_batch do |batch|
285
+ entries = []
286
+ batch.each do |kind, *args|
287
+ case kind
288
+ when :match, :document
289
+ name, root = valid_roots[args[0]]
290
+ path = args[1]
291
+ entry = { path: path, bookmark: name, relative_path: path.delete_prefix(root + File::SEPARATOR) }
292
+ if kind == :document
293
+ scan_content(entry, query, events)
294
+ else
295
+ entries << entry.merge(line_number: args[2], match_text: args[3])
296
+ end
297
+ when :error then events << [:error, args[1]]
298
+ when :skipped then events << [:skipped, args[0]]
299
+ end
300
+ end
301
+ events << [:entries, entries] unless entries.empty?
302
+ end
303
+ ensure
304
+ engine&.close
305
+ end
306
+
233
307
  def scan_content(entry, query, events)
308
+ if @document_extractor.supported?(entry[:path])
309
+ @document_cache.each_record(entry[:path]) do |record|
310
+ match_index = record[:search_text].index(query)
311
+ next unless match_index
312
+
313
+ snippet = record[:text][[match_index - 30, 0].max, 200].to_s.strip
314
+ metadata = record.reject { |key, _| key == :text || key == :search_text }
315
+ events << [:entry, entry.merge(metadata).merge(match_text: snippet)]
316
+ end
317
+ return
318
+ end
319
+
234
320
  File.open(entry[:path], 'rb') do |file|
235
321
  sample = file.read(8192).to_s
236
322
  if sample.include?("\0")
@@ -249,13 +335,39 @@ module Rufio
249
335
  events << [:entry, entry.merge(line_number: number, match_text: snippet)]
250
336
  end
251
337
  end
252
- rescue SystemCallError, IOError => e
338
+ rescue SystemCallError, IOError, DocumentTextExtractor::ExtractionError => e
253
339
  events << [:error, "#{entry[:path]}: #{e.message}"]
254
340
  end
255
341
 
256
- def write_line(screen, y, text, width, color = nil)
342
+ def write_line(screen, y, text, width, color = nil, highlight_from: nil)
257
343
  safe_text = text.scrub.gsub(/[[:cntrl:]]/, ' ')
258
- screen.put_string(0, y, TextUtils.truncate_to_width(safe_text, width), fg: color)
344
+ visible = TextUtils.truncate_to_width(safe_text, width)
345
+ screen.put_string(0, y, visible, fg: color)
346
+ return unless highlight_from
347
+
348
+ match_ranges(visible, highlight_from).each do |start, length|
349
+ x = TextUtils.display_width(visible[0, start])
350
+ screen.put_string(x, y, visible[start, length], fg: MATCH_FG, bg: MATCH_BG)
351
+ end
352
+ end
353
+
354
+ # 検索と同じく大文字小文字を区別せず、from 文字目以降の一致位置を [開始, 長さ] で返す
355
+ def match_ranges(text, from)
356
+ needle = @query.to_s.downcase.chars
357
+ return [] if needle.empty?
358
+
359
+ folded = text.chars.map(&:downcase)
360
+ ranges = []
361
+ index = from
362
+ while index + needle.length <= folded.length
363
+ if folded[index, needle.length] == needle
364
+ ranges << [index, needle.length]
365
+ index += needle.length
366
+ else
367
+ index += 1
368
+ end
369
+ end
370
+ ranges
259
371
  end
260
372
  end
261
373
  end
@@ -54,26 +54,31 @@ module Rufio
54
54
  'Navigation',
55
55
  "#{key(:move_down)}/#{key(:move_up)} or arrows: Move down/up",
56
56
  "#{key(:navigate_parent)}/#{key(:navigate_enter)}: Parent / enter directory",
57
- 'Enter: Open file preview | ESC: Back to file list',
57
+ "#{key(:preview)}: Fullscreen file preview | ESC: Back to file list",
58
58
  'Preview: j/k scroll, Ctrl+D/U page down/up',
59
59
  "#{key(:top)}/#{key(:bottom)}: First / last entry",
60
60
  "#{key(:open_file)}: Open in external editor",
61
+ "#{key(:open_explorer)}: Open directory in file explorer",
62
+ 'Preview .zip: Entry list (size, method, date, name)',
61
63
  "#{key(:refresh)}: Refresh directory",
62
64
  '', 'Selection and file operations',
63
65
  "#{key(:select)}: Select / unselect file",
64
66
  "#{key(:copy_selected)}/#{key(:move_selected)}/#{key(:delete_selected)}: Copy / move / delete selected files",
67
+ "#{key(:zoxide)}: Extract selected .zip files / compress selected items",
68
+ " Runs as a job: #{key(:job_mode)} Jobs mode shows progress; x cancels",
65
69
  "#{key(:rename)}/#{key(:delete)}: Rename / delete current file",
66
70
  "#{key(:create_file)}/#{key(:create_dir)}: Create file / directory",
67
71
  '', 'Search and bookmarks',
68
72
  "#{key(:filter)}: Filter | Enter: Apply | ESC: Clear",
69
- "#{key(:fzf_search)}: Filename search (fzf)",
73
+ "#{key(:fzf_search)} or #{key(:fzf_search_alt)}: Filename search (fzf)",
70
74
  "#{key(:rga_search)}: Content search (rga)",
71
75
  "#{key(:global_search)}: Search file names or contents across all bookmarks",
72
76
  "#{key(:add_bookmark)}/#{key(:bookmark_menu)}: Add / manage bookmarks",
73
77
  'Tab / Shift+Tab: Next / previous bookmark',
74
78
  "1-9: Jump to bookmark | #{key(:start_dir)}: Start directory",
75
- "#{key(:zoxide)}: Directory history (zoxide)",
76
- "#{key(:quit)}: Quit (with confirmation)"
79
+ "#{key(:zoxide)}: Directory history (zoxide, when nothing is selected)",
80
+ "#{key(:quit)}: Quit (with confirmation)",
81
+ "#{key(:quit_cd)}: Quit and cd to the current directory (rufio --shell-init)"
77
82
  ]
78
83
  when 'Modes'
79
84
  [
@@ -87,20 +92,25 @@ module Rufio
87
92
  'Search file names or text inside every bookmarked directory.',
88
93
  'Tab: Switch File names / Content (literal, case insensitive)',
89
94
  'Content results show file, line number and matching text.',
95
+ 'Documents: .pdf (page), .xlsx (sheet/cell), .pptx (slide)',
96
+ 'Ruby extracts document text; no Python or Office is needed.',
97
+ 'Unchanged document text is cached in memory for faster searches.',
98
+ 'Images need OCR; legacy .xls/.ppt and slide notes are unsupported.',
90
99
  'Yellow Searching... + spinner: Search is running.',
91
100
  'Green Search complete: All bookmarked directories scanned.',
92
101
  'Exclude extensions in ~/.config/rufio/config.rb:',
93
102
  'GLOBAL_SEARCH_EXCLUDED_EXTENSIONS = %w[.zip .log]',
94
103
  'Applies to names and contents. Restart Rufio after editing.',
95
104
  'Extensions ignore case; leading dots are optional; [] excludes none.',
96
- 'Type to filter | Up/Down or Ctrl+N/P: Select',
105
+ 'Type to filter | Up/Down or Ctrl+J/K or Ctrl+N/P: Select',
97
106
  'Enter: Go to the selected file in Files mode',
98
107
  'ESC: Return to the previous mode | Ctrl+U: Clear query',
99
108
  '', "[#{key(:log_viewer)}] Logs",
100
- 'Browse command logs. Enter opens a preview.',
109
+ "Browse command logs. #{key(:preview)} opens a fullscreen preview.",
101
110
  'ESC: Leave preview; ESC again: Return to Files.',
102
111
  '', "[#{key(:job_mode)}] Jobs",
103
112
  'View background commands and their status.',
113
+ 'ZIP extract/compress jobs show progress here.',
104
114
  'Space: View log | x: Cancel job | ESC: Back',
105
115
  '', "[#{key(:help)}] Help",
106
116
  'This floating menu keeps your current mode and directory.',
@@ -109,6 +119,7 @@ module Rufio
109
119
  'Type a command, then Enter to run it.',
110
120
  'Tab: Complete | Up/Down: History | ESC: Cancel',
111
121
  ':!git status: Run a shell command',
122
+ ':cd <path>: Go to a pasted path (file: select it, cd -: back)',
112
123
  ':@hello.rb: Run a script in the current directory'
113
124
  ]
114
125
  when 'Scripts'
@@ -155,7 +166,8 @@ module Rufio
155
166
  DialogRenderer.new.draw_floating_window_to_overlay(
156
167
  screen, (screen.width - width) / 2, (screen.height - height) / 2,
157
168
  width, height, TextUtils.truncate_to_width(title, width - 2), visible,
158
- border_color: "\e[36m", title_color: "\e[1;36m"
169
+ border_color: "\e[36m", title_color: "\e[1;36m",
170
+ scroll_offset: @scroll_offset, total_lines: lines.length, visible_lines: @page_size
159
171
  )
160
172
  end
161
173
 
@@ -1,20 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'script_file'
4
+
3
5
  module Rufio
4
6
  # 拡張子からインタープリタを解決するクラス
5
7
  class InterpreterResolver
6
8
  # デフォルトの拡張子とインタープリタのマッピング
7
- DEFAULT_EXTENSIONS = {
8
- ".rb" => "ruby",
9
- ".py" => "python3",
10
- ".sh" => "bash",
11
- ".js" => "node",
12
- ".pl" => "perl",
13
- ".lua" => "lua",
14
- ".ts" => "ts-node",
15
- ".php" => "php",
16
- ".ps1" => nil # プラットフォーム依存
17
- }.freeze
9
+ # DSLでは .sh にインタープリタを指定し、WindowsのPowerShellを別途解決する。
10
+ DEFAULT_EXTENSIONS = ScriptFile::INTERPRETERS.merge(
11
+ ".sh" => "bash", ".lua" => "lua", ".php" => "php", ".ps1" => nil
12
+ ).freeze
18
13
 
19
14
  class << self
20
15
  # 拡張子からインタープリタを解決する
@@ -23,12 +23,9 @@ module Rufio
23
23
  job = TaskStatus.new(
24
24
  id: @next_id,
25
25
  name: name,
26
- path: path
26
+ path: path,
27
+ command: command
27
28
  )
28
- # commandを保存(TaskStatusに追加)
29
- job.instance_variable_set(:@command, command)
30
- job.define_singleton_method(:command) { @command }
31
-
32
29
  @jobs << job
33
30
  @next_id += 1
34
31
  job
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'io/console'
4
+
5
+ module Rufio
6
+ # ダイアログなどのブロッキングなキー入力の読み取り口。
7
+ # Windows ではメインループ用の入力スレッドが常に STDIN.getch で待機しているため、
8
+ # ダイアログが STDIN.getch を直接呼ぶと 1 キーずつ入力を奪い合い、2 回押さないと入力できない。
9
+ # 入力キューが接続されている間はキューから読み、それ以外は STDIN.getch を使う。
10
+ module KeyInput
11
+ # Ruby 3.2+ は Queue#pop(timeout:) で正確に待機する(sleep ポーリングは
12
+ # Windows のタイマー分解能 約15ms により遅延する)。それ以前はポーリング。
13
+ QUEUE_POP_TIMEOUT_SUPPORTED = Queue.instance_method(:pop).parameters.any? { |_, n| n == :timeout }
14
+ MULTIBYTE_TIMEOUT = 0.005
15
+
16
+ class << self
17
+ attr_reader :queue
18
+
19
+ def attach(queue)
20
+ @queue = queue
21
+ end
22
+
23
+ def detach
24
+ @queue = nil
25
+ end
26
+
27
+ # 1 文字(マルチバイト対応)を読むまでブロックする
28
+ def getch
29
+ queue = @queue
30
+ return STDIN.getch unless queue
31
+
32
+ loop do
33
+ char = build_char(queue.pop, queue)
34
+ return char if char
35
+ end
36
+ end
37
+
38
+ # キューからタイムアウト付きで Integer バイトを取り出す
39
+ def pop_with_timeout(queue, timeout_sec)
40
+ return queue.pop(timeout: timeout_sec) if QUEUE_POP_TIMEOUT_SUPPORTED
41
+
42
+ deadline = Time.now + timeout_sec
43
+ loop do
44
+ begin
45
+ return queue.pop(true)
46
+ rescue ThreadError
47
+ return nil if Time.now >= deadline
48
+
49
+ sleep 0.001
50
+ end
51
+ end
52
+ end
53
+
54
+ # first_byte_int に続く UTF-8 の後続バイトをキューから読み、1 文字に組み立てる
55
+ def build_char(first_byte_int, queue)
56
+ remaining = case first_byte_int
57
+ when 0x00..0x7F then 0
58
+ when 0xC0..0xDF then 1
59
+ when 0xE0..0xEF then 2
60
+ when 0xF0..0xF7 then 3
61
+ else 0
62
+ end
63
+ return first_byte_int.chr(Encoding::UTF_8) if remaining == 0
64
+
65
+ buf = first_byte_int.chr(Encoding::BINARY)
66
+ remaining.times do
67
+ b = pop_with_timeout(queue, MULTIBYTE_TIMEOUT)
68
+ return nil if b.nil?
69
+
70
+ buf << b.chr(Encoding::BINARY)
71
+ end
72
+ result = buf.force_encoding(Encoding::UTF_8)
73
+ result.valid_encoding? ? result : nil
74
+ end
75
+ end
76
+ end
77
+ end