rufio 1.0.4 → 1.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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -0
  3. data/README.md +105 -2
  4. data/README_ja.md +103 -2
  5. data/Rakefile +14 -1
  6. data/bin/rufio +31 -2
  7. data/docs/fast_zip_design_ja.md +167 -0
  8. data/examples/config.rb +8 -0
  9. data/info/help.md +2 -0
  10. data/info/keybindings.md +3 -1
  11. data/info/welcome.md +1 -1
  12. data/lib/rufio/application.rb +12 -1
  13. data/lib/rufio/archive_controller.rb +211 -0
  14. data/lib/rufio/background_command_executor.rb +9 -0
  15. data/lib/rufio/background_status.rb +49 -0
  16. data/lib/rufio/bookmark_controller.rb +38 -121
  17. data/lib/rufio/bookmark_manager.rb +77 -331
  18. data/lib/rufio/command_completion.rb +6 -1
  19. data/lib/rufio/command_history.rb +5 -2
  20. data/lib/rufio/command_mode.rb +40 -134
  21. data/lib/rufio/command_mode_ui.rb +4 -42
  22. data/lib/rufio/config.rb +89 -137
  23. data/lib/rufio/config_loader.rb +21 -13
  24. data/lib/rufio/dialog_renderer.rb +64 -152
  25. data/lib/rufio/dialog_support.rb +69 -0
  26. data/lib/rufio/document_text_extractor.rb +167 -0
  27. data/lib/rufio/file_operation_controller.rb +49 -222
  28. data/lib/rufio/file_preview.rb +46 -2
  29. data/lib/rufio/global_search.rb +373 -0
  30. data/lib/rufio/help_menu.rb +27 -2
  31. data/lib/rufio/interpreter_resolver.rb +6 -11
  32. data/lib/rufio/job_manager.rb +2 -5
  33. data/lib/rufio/key_input.rb +77 -0
  34. data/lib/rufio/keybind_handler.rb +120 -112
  35. data/lib/rufio/local_script_scanner.rb +6 -18
  36. data/lib/rufio/native/aarch64-linux-gnu/librufio_zig.so +0 -0
  37. data/lib/rufio/native/aarch64-linux-musl/librufio_zig.so +0 -0
  38. data/lib/rufio/native/aarch64-macos/librufio_zig.dylib +0 -0
  39. data/lib/rufio/native/aarch64-windows/rufio_zig.dll +0 -0
  40. data/lib/rufio/native/x86_64-linux-gnu/librufio_zig.so +0 -0
  41. data/lib/rufio/native/x86_64-linux-musl/librufio_zig.so +0 -0
  42. data/lib/rufio/native/x86_64-macos/librufio_zig.dylib +0 -0
  43. data/lib/rufio/native/x86_64-windows/rufio_zig.dll +0 -0
  44. data/lib/rufio/native_content_search.rb +153 -0
  45. data/lib/rufio/native_scanner.rb +13 -68
  46. data/lib/rufio/native_scanner_zig.rb +106 -56
  47. data/lib/rufio/native_zip.rb +121 -0
  48. data/lib/rufio/navigation_controller.rb +18 -20
  49. data/lib/rufio/screen.rb +2 -12
  50. data/lib/rufio/script_executor.rb +6 -73
  51. data/lib/rufio/script_file.rb +44 -0
  52. data/lib/rufio/script_path_manager.rb +9 -43
  53. data/lib/rufio/script_runner.rb +13 -50
  54. data/lib/rufio/search_controller.rb +1 -1
  55. data/lib/rufio/shell_command_completion.rb +1 -5
  56. data/lib/rufio/shell_init.rb +90 -0
  57. data/lib/rufio/tab_mode_manager.rb +5 -1
  58. data/lib/rufio/task_status.rb +5 -1
  59. data/lib/rufio/terminal_ui.rb +116 -293
  60. data/lib/rufio/text_utils.rb +26 -3
  61. data/lib/rufio/ui_renderer.rb +68 -41
  62. data/lib/rufio/version.rb +1 -1
  63. data/lib/rufio/zip_archive.rb +314 -0
  64. data/lib/rufio/zip_extractor.rb +346 -0
  65. data/lib/rufio/zip_writer.rb +268 -0
  66. data/lib/rufio/zoxide_integration.rb +5 -43
  67. data/lib/rufio.rb +5 -0
  68. data/lib_zig/rufio_native/Makefile +54 -17
  69. data/lib_zig/rufio_native/bench.sh +36 -0
  70. data/lib_zig/rufio_native/build.zig +32 -35
  71. data/lib_zig/rufio_native/src/cli.zig +99 -0
  72. data/lib_zig/rufio_native/src/main.zig +187 -83
  73. data/lib_zig/rufio_native/src/os.zig +889 -0
  74. data/lib_zig/rufio_native/src/search.zig +919 -0
  75. data/lib_zig/rufio_native/src/search_test.zig +88 -0
  76. data/lib_zig/rufio_native/src/zip.zig +482 -0
  77. metadata +126 -3
  78. data/lib/rufio/native/rufio_zig.bundle +0 -0
@@ -0,0 +1,373 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'thread'
4
+ require_relative 'text_utils'
5
+ require_relative 'config_loader'
6
+ require_relative 'document_text_extractor'
7
+ require_relative 'native_content_search'
8
+
9
+ module Rufio
10
+ # ブックマーク配下のファイル名・テキスト内容をバックグラウンドで横断検索する。
11
+ class GlobalSearch
12
+ SPINNER_FRAMES = ['|', '/', '-', '\\'].freeze
13
+ SPINNER_INTERVAL = 0.12
14
+ # 検索語に一致した部分の表示(黒の太字・黄色背景)
15
+ MATCH_FG = "\e[30m\e[1m"
16
+ MATCH_BG = "\e[43m"
17
+
18
+ attr_reader :query, :results, :errors, :selected_index, :search_type, :skipped_files
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
+
30
+ def initialize
31
+ @active = false
32
+ @scanning = false
33
+ @worker = nil
34
+ @document_extractor = DocumentTextExtractor.new
35
+ @document_cache = DocumentTextCache.new(extractor: @document_extractor)
36
+ end
37
+
38
+ def active?
39
+ @active
40
+ end
41
+
42
+ def scanning?
43
+ @scanning
44
+ end
45
+
46
+ def open(bookmarks)
47
+ close
48
+ @active = true
49
+ @query = ''
50
+ @search_type = :filename
51
+ @excluded_extensions = ConfigLoader.global_search_excluded_extensions
52
+ @bookmark_count = bookmarks.length
53
+ @roots = bookmarks.map { |bookmark| [bookmark[:name].to_s, bookmark[:path].to_s] }
54
+ start_scan
55
+ true
56
+ end
57
+
58
+ def close
59
+ @active = false
60
+ @scanning = false
61
+ @worker&.kill
62
+ @worker&.join
63
+ @worker = nil
64
+ end
65
+
66
+ # 1フレーム当たりの処理量を制限し、検索中も入力を受け付ける。
67
+ def poll
68
+ return false unless active?
69
+
70
+ changed = false
71
+ 256.times do
72
+ event = begin
73
+ @events.pop(true)
74
+ rescue ThreadError
75
+ break
76
+ end
77
+ changed = true
78
+ type, value = event
79
+ case type
80
+ when :entry
81
+ @entries << value
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)
87
+ when :error then @errors << value
88
+ when :skipped then @skipped_files += value || 1
89
+ when :done then @scanning = false
90
+ end
91
+ end
92
+ if scanning?
93
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
94
+ if now - @last_spinner_update >= SPINNER_INTERVAL
95
+ @spinner_index = (@spinner_index + 1) % SPINNER_FRAMES.length
96
+ @last_spinner_update = now
97
+ changed = true
98
+ end
99
+ end
100
+ changed
101
+ end
102
+
103
+ def handle_key(key)
104
+ case key
105
+ when "\e" then close
106
+ when "\t"
107
+ @search_type = @search_type == :filename ? :content : :filename
108
+ start_scan
109
+ when "\r", "\n" then return @results[@selected_index]
110
+ when :down then @selected_index = [@selected_index + 1, [@results.length - 1, 0].max].min
111
+ when :up then @selected_index = [@selected_index - 1, 0].max
112
+ when "\x0e" then handle_key(:down) # Ctrl+N
113
+ when "\x10" then handle_key(:up) # Ctrl+P
114
+ when "\x15" then update_query('') # Ctrl+U
115
+ when "\u007f", "\b" then update_query(@query[0...-1])
116
+ else
117
+ update_query(@query + key) if key.is_a?(String) && !key.empty? && key.each_codepoint.all? { |c| c >= 32 && c != 127 }
118
+ end
119
+ nil
120
+ end
121
+
122
+ def draw(screen)
123
+ screen.clear
124
+ width = screen.width
125
+ type = @search_type == :content ? 'Content' : 'File names'
126
+ write_line(screen, 0, "G mode - #{type} | Tab: Switch name/content search", width, "\e[1;36m")
127
+ write_line(screen, 1, "Search: #{@query}_", width)
128
+ state = if scanning?
129
+ "[#{SPINNER_FRAMES[@spinner_index]}] Searching..."
130
+ elsif @search_type == :content && @query.empty?
131
+ 'Ready'
132
+ else
133
+ 'Search complete'
134
+ end
135
+ color = scanning? ? "\e[1;33m" : "\e[32m"
136
+ write_line(screen, 2, "#{state} | #{@bookmark_count} bookmarks | #{@results.length} matches | #{@errors.length} errors | #{@skipped_files} skipped", width, color)
137
+ row_height = @search_type == :content ? 2 : 1
138
+ capacity = [screen.height - 6, 0].max / row_height
139
+ first = [@selected_index - capacity + 1, 0].max
140
+ @results.slice(first, capacity).to_a.each_with_index do |entry, index|
141
+ selected = first + index == @selected_index
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
151
+ y = index * row_height + 3
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]
156
+ end
157
+ if @results.empty?
158
+ message = if @bookmark_count.zero?
159
+ 'No bookmarks. Add directories with b in Files mode.'
160
+ elsif scanning?
161
+ 'Searching bookmarked directories...'
162
+ elsif @search_type == :content && @query.empty?
163
+ 'Type a string to search inside text files.'
164
+ elsif !@errors.empty? && @entries.empty?
165
+ 'No readable files in bookmarked directories.'
166
+ else
167
+ 'No matching files. Edit your search to try again.'
168
+ end
169
+ write_line(screen, 3, message, width)
170
+ end
171
+ write_line(screen, screen.height - 3, @results[@selected_index]&.[](:path) || @errors.first.to_s, width, "\e[90m")
172
+ write_line(screen, screen.height - 2, 'ESC: Back | Enter: Go to file | Up/Down: Select | Type: Search', width)
173
+ end
174
+
175
+ private
176
+
177
+ def start_scan
178
+ @worker&.kill
179
+ @worker&.join
180
+ @worker = nil
181
+ @entries = []
182
+ @results = []
183
+ @errors = []
184
+ @skipped_files = 0
185
+ @selected_index = 0
186
+ @events = SizedQueue.new(256)
187
+ @spinner_index = 0
188
+ @last_spinner_update = Process.clock_gettime(Process::CLOCK_MONOTONIC)
189
+ @scanning = !(@search_type == :content && @query.empty?)
190
+ return unless @scanning
191
+
192
+ roots, events, type, query = @roots, @events, @search_type, @query.downcase
193
+ native_cache = self.class.native_search? ? (@native_cache ||= NativeContentSearch::Cache.new) : nil
194
+ @worker = Thread.new do
195
+ begin
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
201
+ rescue StandardError => e
202
+ events << [:error, e.message]
203
+ end
204
+ events << [:done, nil]
205
+ end
206
+ end
207
+
208
+ def update_query(query)
209
+ @query = query
210
+ @selected_index = 0
211
+ @search_type == :content ? start_scan : filter_results
212
+ end
213
+
214
+ def filter_results
215
+ query = @query.downcase
216
+ @results = @entries.select { |entry| entry[:relative_path].scrub.downcase.include?(query) }
217
+ @selected_index = [[@selected_index, @results.length - 1].min, 0].max
218
+ end
219
+
220
+ def matches_query?(entry)
221
+ entry[:relative_path].scrub.downcase.include?(@query.downcase)
222
+ end
223
+
224
+ def scan_roots(roots, events, type, query)
225
+ visited_dirs = {}
226
+ roots.each do |name, root|
227
+ begin
228
+ root = File.expand_path(root)
229
+ raise Errno::ENOTDIR, root unless File.directory?(root)
230
+
231
+ pending = [root]
232
+ until pending.empty?
233
+ directory = pending.pop
234
+ canonical = File.realpath(directory)
235
+ next if visited_dirs[canonical]
236
+
237
+ visited_dirs[canonical] = true
238
+ begin
239
+ Dir.each_child(directory) do |child|
240
+ path = File.join(directory, child)
241
+ # ディレクトリシンボリックリンクは追わず、循環・範囲外の走査を防ぐ。
242
+ if File.directory?(path)
243
+ pending << path unless File.symlink?(path)
244
+ elsif File.file?(path)
245
+ if child.scrub.downcase.end_with?(*@excluded_extensions)
246
+ events << [:skipped, nil]
247
+ next
248
+ end
249
+
250
+ entry = { path: path, bookmark: name, relative_path: path.delete_prefix(root + File::SEPARATOR) }
251
+ if type == :content
252
+ scan_content(entry, query, events)
253
+ else
254
+ events << [:entry, entry]
255
+ end
256
+ end
257
+ end
258
+ rescue SystemCallError, IOError => e
259
+ events << [:error, "#{directory}: #{e.message}"]
260
+ end
261
+ end
262
+ rescue SystemCallError, IOError => e
263
+ events << [:error, "#{name}: #{e.message}"]
264
+ end
265
+ end
266
+ end
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
+
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
+
320
+ File.open(entry[:path], 'rb') do |file|
321
+ sample = file.read(8192).to_s
322
+ if sample.include?("\0")
323
+ events << [:skipped, nil]
324
+ return
325
+ end
326
+
327
+ file.rewind
328
+ file.each_line.with_index(1) do |raw_line, number|
329
+ line = raw_line.force_encoding(Encoding::UTF_8).scrub
330
+ match_index = line.downcase.index(query)
331
+ next unless match_index
332
+
333
+ snippet_start = [match_index - 30, 0].max
334
+ snippet = line[snippet_start, 200].strip
335
+ events << [:entry, entry.merge(line_number: number, match_text: snippet)]
336
+ end
337
+ end
338
+ rescue SystemCallError, IOError, DocumentTextExtractor::ExtractionError => e
339
+ events << [:error, "#{entry[:path]}: #{e.message}"]
340
+ end
341
+
342
+ def write_line(screen, y, text, width, color = nil, highlight_from: nil)
343
+ safe_text = text.scrub.gsub(/[[:cntrl:]]/, ' ')
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
371
+ end
372
+ end
373
+ end
@@ -62,21 +62,46 @@ module Rufio
62
62
  '', 'Selection and file operations',
63
63
  "#{key(:select)}: Select / unselect file",
64
64
  "#{key(:copy_selected)}/#{key(:move_selected)}/#{key(:delete_selected)}: Copy / move / delete selected files",
65
+ "#{key(:zoxide)}: Extract selected .zip files / compress selected items",
65
66
  "#{key(:rename)}/#{key(:delete)}: Rename / delete current file",
66
67
  "#{key(:create_file)}/#{key(:create_dir)}: Create file / directory",
67
68
  '', 'Search and bookmarks',
68
69
  "#{key(:filter)}: Filter | Enter: Apply | ESC: Clear",
69
70
  "#{key(:fzf_search)}: Filename search (fzf)",
70
71
  "#{key(:rga_search)}: Content search (rga)",
72
+ "#{key(:global_search)}: Search file names or contents across all bookmarks",
71
73
  "#{key(:add_bookmark)}/#{key(:bookmark_menu)}: Add / manage bookmarks",
72
74
  'Tab / Shift+Tab: Next / previous bookmark',
73
75
  "1-9: Jump to bookmark | #{key(:start_dir)}: Start directory",
74
- "#{key(:zoxide)}: Directory history (zoxide)",
75
- "#{key(:quit)}: Quit (with confirmation)"
76
+ "#{key(:zoxide)}: Directory history (zoxide, when nothing is selected)",
77
+ "#{key(:quit)}: Quit (with confirmation)",
78
+ "#{key(:quit_cd)}: Quit and cd to the current directory (rufio --shell-init)"
76
79
  ]
77
80
  when 'Modes'
78
81
  [
79
82
  'Files', 'Browse directories, select files and view previews.',
83
+ '', "[#{key(:config_mode)}] Config",
84
+ 'Open ~/.config/rufio/ to edit settings and scripts.',
85
+ 'The config directory is created if it does not exist.',
86
+ 'Navigation, previews and file operations work as in Files.',
87
+ "#{key(:navigate_parent)}: Parent directory; leaving config returns to Files.",
88
+ '', "[#{key(:global_search)}] Global search",
89
+ 'Search file names or text inside every bookmarked directory.',
90
+ 'Tab: Switch File names / Content (literal, case insensitive)',
91
+ 'Content results show file, line number and matching text.',
92
+ 'Documents: .pdf (page), .xlsx (sheet/cell), .pptx (slide)',
93
+ 'Ruby extracts document text; no Python or Office is needed.',
94
+ 'Unchanged document text is cached in memory for faster searches.',
95
+ 'Images need OCR; legacy .xls/.ppt and slide notes are unsupported.',
96
+ 'Yellow Searching... + spinner: Search is running.',
97
+ 'Green Search complete: All bookmarked directories scanned.',
98
+ 'Exclude extensions in ~/.config/rufio/config.rb:',
99
+ 'GLOBAL_SEARCH_EXCLUDED_EXTENSIONS = %w[.zip .log]',
100
+ 'Applies to names and contents. Restart Rufio after editing.',
101
+ 'Extensions ignore case; leading dots are optional; [] excludes none.',
102
+ 'Type to filter | Up/Down or Ctrl+N/P: Select',
103
+ 'Enter: Go to the selected file in Files mode',
104
+ 'ESC: Return to the previous mode | Ctrl+U: Clear query',
80
105
  '', "[#{key(:log_viewer)}] Logs",
81
106
  'Browse command logs. Enter opens a preview.',
82
107
  'ESC: Leave preview; ESC again: Return to Files.',
@@ -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