rufio 0.9.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 (47) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +188 -0
  3. data/CHANGELOG_v0.4.0.md +146 -0
  4. data/CHANGELOG_v0.5.0.md +26 -0
  5. data/CHANGELOG_v0.6.0.md +182 -0
  6. data/CHANGELOG_v0.7.0.md +280 -0
  7. data/CHANGELOG_v0.8.0.md +267 -0
  8. data/CHANGELOG_v0.9.0.md +279 -0
  9. data/README.md +631 -0
  10. data/README_EN.md +561 -0
  11. data/Rakefile +156 -0
  12. data/bin/rufio +34 -0
  13. data/config_example.rb +88 -0
  14. data/docs/PLUGIN_GUIDE.md +431 -0
  15. data/docs/plugin_example.rb +119 -0
  16. data/lib/rufio/application.rb +32 -0
  17. data/lib/rufio/bookmark.rb +115 -0
  18. data/lib/rufio/bookmark_manager.rb +173 -0
  19. data/lib/rufio/color_helper.rb +150 -0
  20. data/lib/rufio/command_mode.rb +72 -0
  21. data/lib/rufio/command_mode_ui.rb +168 -0
  22. data/lib/rufio/config.rb +199 -0
  23. data/lib/rufio/config_loader.rb +110 -0
  24. data/lib/rufio/dialog_renderer.rb +127 -0
  25. data/lib/rufio/directory_listing.rb +113 -0
  26. data/lib/rufio/file_opener.rb +140 -0
  27. data/lib/rufio/file_operations.rb +231 -0
  28. data/lib/rufio/file_preview.rb +200 -0
  29. data/lib/rufio/filter_manager.rb +114 -0
  30. data/lib/rufio/health_checker.rb +246 -0
  31. data/lib/rufio/keybind_handler.rb +828 -0
  32. data/lib/rufio/logger.rb +103 -0
  33. data/lib/rufio/plugin.rb +89 -0
  34. data/lib/rufio/plugin_config.rb +59 -0
  35. data/lib/rufio/plugin_manager.rb +84 -0
  36. data/lib/rufio/plugins/file_operations.rb +44 -0
  37. data/lib/rufio/selection_manager.rb +79 -0
  38. data/lib/rufio/terminal_ui.rb +630 -0
  39. data/lib/rufio/text_utils.rb +108 -0
  40. data/lib/rufio/version.rb +5 -0
  41. data/lib/rufio/zoxide_integration.rb +188 -0
  42. data/lib/rufio.rb +33 -0
  43. data/publish_gem.zsh +131 -0
  44. data/rufio.gemspec +40 -0
  45. data/test_delete/test1.txt +1 -0
  46. data/test_delete/test2.txt +1 -0
  47. metadata +189 -0
@@ -0,0 +1,630 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'io/console'
4
+
5
+ module Rufio
6
+ class TerminalUI
7
+ # Layout constants
8
+ HEADER_HEIGHT = 2 # Header占有行数
9
+ FOOTER_HEIGHT = 1 # Footer占有行数
10
+ HEADER_FOOTER_MARGIN = 4 # Header + Footer分のマージン
11
+
12
+ # Panel layout ratios
13
+ LEFT_PANEL_RATIO = 0.5 # 左パネルの幅比率
14
+ RIGHT_PANEL_RATIO = 1.0 - LEFT_PANEL_RATIO
15
+
16
+ # Display constants
17
+ DEFAULT_SCREEN_WIDTH = 80 # デフォルト画面幅
18
+ DEFAULT_SCREEN_HEIGHT = 24 # デフォルト画面高さ
19
+ HEADER_PADDING = 2 # ヘッダーのパディング
20
+ BASE_INFO_RESERVED_WIDTH = 20 # ベースディレクトリ表示の予約幅
21
+ BASE_INFO_MIN_WIDTH = 10 # ベースディレクトリ表示の最小幅
22
+ FILTER_TEXT_RESERVED = 15 # フィルタテキスト表示の予約幅
23
+
24
+ # File display constants
25
+ ICON_SIZE_PADDING = 12 # アイコン、選択マーク、サイズ情報分
26
+ CURSOR_OFFSET = 1 # カーソル位置のオフセット
27
+
28
+ # Size display constants (bytes)
29
+ KILOBYTE = 1024
30
+ MEGABYTE = KILOBYTE * 1024
31
+ GIGABYTE = MEGABYTE * 1024
32
+
33
+ # Line offsets
34
+ CONTENT_START_LINE = 3 # コンテンツ開始行(ヘッダー2行スキップ)
35
+
36
+ def initialize
37
+ console = IO.console
38
+ if console
39
+ @screen_width, @screen_height = console.winsize.reverse
40
+ else
41
+ # fallback values (for test environments etc.)
42
+ @screen_width = DEFAULT_SCREEN_WIDTH
43
+ @screen_height = DEFAULT_SCREEN_HEIGHT
44
+ end
45
+ @running = false
46
+ @command_mode_active = false
47
+ @command_input = ""
48
+ @command_mode = CommandMode.new
49
+ @dialog_renderer = DialogRenderer.new
50
+ @command_mode_ui = CommandModeUI.new(@command_mode, @dialog_renderer)
51
+ end
52
+
53
+ def start(directory_listing, keybind_handler, file_preview)
54
+ @directory_listing = directory_listing
55
+ @keybind_handler = keybind_handler
56
+ @file_preview = file_preview
57
+ @keybind_handler.set_directory_listing(@directory_listing)
58
+ @keybind_handler.set_terminal_ui(self)
59
+
60
+ @running = true
61
+ setup_terminal
62
+
63
+ begin
64
+ main_loop
65
+ ensure
66
+ cleanup_terminal
67
+ end
68
+ end
69
+
70
+ def refresh_display
71
+ # ウィンドウサイズを更新してから画面をクリアして再描画
72
+ update_screen_size
73
+ print "\e[2J\e[H" # clear screen, cursor to home
74
+ end
75
+
76
+ private
77
+
78
+ def setup_terminal
79
+ # terminal setup
80
+ system('tput smcup') # alternate screen
81
+ system('tput civis') # cursor invisible
82
+ print "\e[2J\e[H" # clear screen, cursor to home (first time only)
83
+
84
+ # re-acquire terminal size (just in case)
85
+ update_screen_size
86
+ end
87
+
88
+ def update_screen_size
89
+ console = IO.console
90
+ return unless console
91
+
92
+ @screen_width, @screen_height = console.winsize.reverse
93
+ end
94
+
95
+ def cleanup_terminal
96
+ system('tput rmcup') # normal screen
97
+ system('tput cnorm') # cursor normal
98
+ puts ConfigLoader.message('app.terminated')
99
+ end
100
+
101
+ def main_loop
102
+ while @running
103
+ draw_screen
104
+ handle_input
105
+ end
106
+ end
107
+
108
+ def draw_screen
109
+ # move cursor to top of screen (don't clear)
110
+ print "\e[H"
111
+
112
+ # header (2 lines)
113
+ draw_header
114
+ draw_base_directory_info
115
+
116
+ # main content (left: directory list, right: preview)
117
+ entries = get_display_entries
118
+ selected_entry = entries[@keybind_handler.current_index]
119
+
120
+ # calculate height with header and footer margin
121
+ content_height = @screen_height - HEADER_FOOTER_MARGIN
122
+ left_width = (@screen_width * LEFT_PANEL_RATIO).to_i
123
+ right_width = @screen_width - left_width
124
+
125
+ # adjust so right panel doesn't overflow into left panel
126
+ right_width = @screen_width - left_width if left_width + right_width > @screen_width
127
+
128
+ draw_directory_list(entries, left_width, content_height)
129
+ draw_file_preview(selected_entry, right_width, content_height, left_width)
130
+
131
+ # footer
132
+ draw_footer
133
+
134
+ # コマンドモードがアクティブな場合はコマンド入力ウィンドウを表示
135
+ if @command_mode_active
136
+ # 補完候補を取得
137
+ suggestions = @command_mode_ui.autocomplete(@command_input)
138
+ # フローティングウィンドウで表示
139
+ @command_mode_ui.show_input_prompt(@command_input, suggestions)
140
+ else
141
+ # move cursor to invisible position
142
+ print "\e[#{@screen_height};#{@screen_width}H"
143
+ end
144
+ end
145
+
146
+ def draw_header
147
+ current_path = @directory_listing.current_path
148
+ header = "📁 rufio - #{current_path}"
149
+
150
+ # Add filter indicator if in filter mode
151
+ if @keybind_handler.filter_active?
152
+ filter_text = " [Filter: #{@keybind_handler.filter_query}]"
153
+ header += filter_text
154
+ end
155
+
156
+ # abbreviate if path is too long
157
+ if header.length > @screen_width - HEADER_PADDING
158
+ if @keybind_handler.filter_active?
159
+ # prioritize showing filter when active
160
+ filter_text = " [Filter: #{@keybind_handler.filter_query}]"
161
+ base_length = @screen_width - filter_text.length - FILTER_TEXT_RESERVED
162
+ header = "📁 rufio - ...#{current_path[-base_length..-1]}#{filter_text}"
163
+ else
164
+ header = "📁 rufio - ...#{current_path[-(@screen_width - FILTER_TEXT_RESERVED)..-1]}"
165
+ end
166
+ end
167
+
168
+ puts "\e[7m#{header.ljust(@screen_width)}\e[0m" # reverse display
169
+ end
170
+
171
+ def draw_base_directory_info
172
+ # 強制的に表示 - デバッグ用に安全チェックを緩和
173
+ if @keybind_handler && @keybind_handler.instance_variable_get(:@base_directory)
174
+ base_dir = @keybind_handler.instance_variable_get(:@base_directory)
175
+ selected_count = @keybind_handler.selected_items.length
176
+ base_info = "📋 Base Directory: #{base_dir}"
177
+
178
+ # 選択されたアイテム数を表示
179
+ if selected_count > 0
180
+ base_info += " | Selected: #{selected_count} item(s)"
181
+ end
182
+ else
183
+ # keybind_handlerがない場合、またはbase_directoryが設定されていない場合
184
+ base_info = "📋 Base Directory: #{Dir.pwd}"
185
+ end
186
+
187
+ # 長すぎる場合は省略
188
+ if base_info.length > @screen_width - HEADER_PADDING
189
+ if base_info.include?(" | Selected:")
190
+ selected_part = base_info.split(" | Selected:").last
191
+ available_length = @screen_width - BASE_INFO_RESERVED_WIDTH - " | Selected:#{selected_part}".length
192
+ else
193
+ available_length = @screen_width - BASE_INFO_RESERVED_WIDTH
194
+ end
195
+
196
+ if available_length > BASE_INFO_MIN_WIDTH
197
+ # パスの最後の部分を表示
198
+ dir_part = base_info.split(": ").last.split(" | ").first
199
+ short_base_dir = "...#{dir_part[-available_length..-1]}"
200
+ base_info = base_info.gsub(dir_part, short_base_dir)
201
+ end
202
+ end
203
+
204
+ # 2行目に確実に表示
205
+ print "\e[2;1H\e[44m\e[37m#{base_info.ljust(@screen_width)}\e[0m"
206
+ end
207
+
208
+
209
+ def draw_directory_list(entries, width, height)
210
+ start_index = [@keybind_handler.current_index - height / 2, 0].max
211
+ [start_index + height - 1, entries.length - 1].min
212
+
213
+ (0...height).each do |i|
214
+ entry_index = start_index + i
215
+ line_num = i + CONTENT_START_LINE
216
+
217
+ print "\e[#{line_num};1H" # set cursor position
218
+
219
+ if entry_index < entries.length
220
+ entry = entries[entry_index]
221
+ is_selected = entry_index == @keybind_handler.current_index
222
+
223
+ draw_entry_line(entry, width, is_selected)
224
+ else
225
+ # 左ペイン専用の安全な幅で空行を出力
226
+ safe_width = [width - CURSOR_OFFSET, (@screen_width * LEFT_PANEL_RATIO).to_i - CURSOR_OFFSET].min
227
+ print ' ' * safe_width
228
+ end
229
+ end
230
+ end
231
+
232
+ def draw_entry_line(entry, width, is_selected)
233
+ # アイコンと色の設定
234
+ icon, color = get_entry_display_info(entry)
235
+
236
+ # 左ペイン専用の安全な幅を計算(右ペインにはみ出さないよう)
237
+ safe_width = [width - CURSOR_OFFSET, (@screen_width * LEFT_PANEL_RATIO).to_i - CURSOR_OFFSET].min
238
+
239
+ # 選択マークの追加
240
+ selection_mark = @keybind_handler.is_selected?(entry[:name]) ? "✓ " : " "
241
+
242
+ # ファイル名(必要に応じて切り詰め)
243
+ name = entry[:name]
244
+ max_name_length = safe_width - ICON_SIZE_PADDING
245
+ name = name[0...max_name_length - 3] + '...' if max_name_length > 0 && name.length > max_name_length
246
+
247
+ # サイズ情報
248
+ size_info = format_size(entry[:size])
249
+
250
+ # 行の内容を構築(安全な幅内で)
251
+ content_without_size = "#{selection_mark}#{icon} #{name}"
252
+ available_for_content = safe_width - size_info.length
253
+
254
+ line_content = if available_for_content > 0
255
+ content_without_size.ljust(available_for_content) + size_info
256
+ else
257
+ content_without_size
258
+ end
259
+
260
+ # 確実に safe_width を超えないよう切り詰め
261
+ line_content = line_content[0...safe_width]
262
+
263
+ if is_selected
264
+ selected_color = ColorHelper.color_to_selected_ansi(ConfigLoader.colors[:selected])
265
+ print "#{selected_color}#{line_content}#{ColorHelper.reset}"
266
+ else
267
+ # 選択されたアイテムは異なる色で表示
268
+ if @keybind_handler.is_selected?(entry[:name])
269
+ print "\e[42m\e[30m#{line_content}\e[0m" # 緑背景、黒文字
270
+ else
271
+ print "#{color}#{line_content}#{ColorHelper.reset}"
272
+ end
273
+ end
274
+ end
275
+
276
+ def get_entry_display_info(entry)
277
+ colors = ConfigLoader.colors
278
+
279
+ case entry[:type]
280
+ when 'directory'
281
+ color_code = ColorHelper.color_to_ansi(colors[:directory])
282
+ ['📁', color_code]
283
+ when 'executable'
284
+ color_code = ColorHelper.color_to_ansi(colors[:executable])
285
+ ['⚡', color_code]
286
+ else
287
+ case File.extname(entry[:name]).downcase
288
+ when '.rb'
289
+ ['💎', "\e[31m"] # 赤
290
+ when '.js', '.ts'
291
+ ['📜', "\e[33m"] # 黄
292
+ when '.txt', '.md'
293
+ color_code = ColorHelper.color_to_ansi(colors[:file])
294
+ ['📄', color_code]
295
+ else
296
+ color_code = ColorHelper.color_to_ansi(colors[:file])
297
+ ['📄', color_code]
298
+ end
299
+ end
300
+ end
301
+
302
+ def format_size(size)
303
+ return ' ' if size == 0
304
+
305
+ if size < KILOBYTE
306
+ "#{size}B".rjust(6)
307
+ elsif size < MEGABYTE
308
+ "#{(size / KILOBYTE.to_f).round(1)}K".rjust(6)
309
+ elsif size < GIGABYTE
310
+ "#{(size / MEGABYTE.to_f).round(1)}M".rjust(6)
311
+ else
312
+ "#{(size / GIGABYTE.to_f).round(1)}G".rjust(6)
313
+ end
314
+ end
315
+
316
+ def draw_file_preview(selected_entry, width, height, left_offset)
317
+ (0...height).each do |i|
318
+ line_num = i + CONTENT_START_LINE
319
+ # カーソル位置を左パネルの右端に設定
320
+ cursor_position = left_offset + CURSOR_OFFSET
321
+
322
+ # 画面の境界を厳密に計算
323
+ max_chars_from_cursor = @screen_width - cursor_position
324
+ # 区切り線(│)分を除いて、さらに安全マージンを取る
325
+ safe_width = [max_chars_from_cursor - 2, width - 2, 0].max
326
+
327
+ print "\e[#{line_num};#{cursor_position}H" # カーソル位置設定
328
+ print '│' # 区切り線
329
+
330
+ content_to_print = ''
331
+
332
+ if selected_entry && i == 0
333
+ # プレビューヘッダー
334
+ header = " #{selected_entry[:name]} "
335
+ content_to_print = header
336
+ elsif selected_entry && selected_entry[:type] == 'file' && i >= 2
337
+ # ファイルプレビュー(折り返し対応)
338
+ preview_content = get_preview_content(selected_entry)
339
+ wrapped_lines = wrap_preview_lines(preview_content, safe_width - 1) # スペース分を除く
340
+ display_line_index = i - 2
341
+
342
+ if display_line_index < wrapped_lines.length
343
+ line = wrapped_lines[display_line_index] || ''
344
+ # スペースを先頭に追加
345
+ content_to_print = " #{line}"
346
+ else
347
+ content_to_print = ' '
348
+ end
349
+ else
350
+ content_to_print = ' '
351
+ end
352
+
353
+ # 絶対にsafe_widthを超えないよう強制的に切り詰める
354
+ if safe_width <= 0
355
+ # 表示スペースがない場合は何も出力しない
356
+ next
357
+ elsif display_width(content_to_print) > safe_width
358
+ # 表示幅ベースで切り詰める
359
+ content_to_print = truncate_to_width(content_to_print, safe_width)
360
+ end
361
+
362
+ # 出力(パディングなし、はみ出し防止のため)
363
+ print content_to_print
364
+
365
+ # 残りのスペースを埋める(ただし安全な範囲内のみ)
366
+ remaining_space = safe_width - display_width(content_to_print)
367
+ print ' ' * remaining_space if remaining_space > 0
368
+ end
369
+ end
370
+
371
+ def get_preview_content(entry)
372
+ return [] unless entry && entry[:type] == 'file'
373
+
374
+ preview = @file_preview.preview_file(entry[:path])
375
+ case preview[:type]
376
+ when 'text', 'code'
377
+ preview[:lines]
378
+ when 'binary'
379
+ ["(#{ConfigLoader.message('file.binary_file')})", ConfigLoader.message('file.cannot_preview')]
380
+ when 'error'
381
+ ["#{ConfigLoader.message('file.error_prefix')}:", preview[:message]]
382
+ else
383
+ ["(#{ConfigLoader.message('file.cannot_preview')})"]
384
+ end
385
+ rescue StandardError
386
+ ["(#{ConfigLoader.message('file.preview_error')})"]
387
+ end
388
+
389
+ def wrap_preview_lines(lines, max_width)
390
+ return [] if lines.empty? || max_width <= 0
391
+
392
+ wrapped_lines = []
393
+
394
+ lines.each do |line|
395
+ if display_width(line) <= max_width
396
+ # 短い行はそのまま追加
397
+ wrapped_lines << line
398
+ else
399
+ # 長い行は折り返し
400
+ remaining_line = line
401
+ while display_width(remaining_line) > max_width
402
+ # 単語境界で折り返すことを試みる
403
+ break_point = find_break_point(remaining_line, max_width)
404
+ wrapped_lines << remaining_line[0...break_point]
405
+ remaining_line = remaining_line[break_point..-1]
406
+ end
407
+ # 残りの部分を追加
408
+ wrapped_lines << remaining_line if remaining_line.length > 0
409
+ end
410
+ end
411
+
412
+ wrapped_lines
413
+ end
414
+
415
+ def display_width(string)
416
+ # 文字列の表示幅を計算する
417
+ # 日本語文字(全角)は幅2、ASCII文字(半角)は幅1として計算
418
+ width = 0
419
+ string.each_char do |char|
420
+ # 全角文字の判定
421
+ width += if char.ord > 127 || char.match?(/[あ-ん ア-ン 一-龯]/)
422
+ 2
423
+ else
424
+ 1
425
+ end
426
+ end
427
+ width
428
+ end
429
+
430
+ def truncate_to_width(string, max_width)
431
+ # 表示幅を指定して文字列を切り詰める
432
+ return string if display_width(string) <= max_width
433
+
434
+ current_width = 0
435
+ result = ''
436
+
437
+ string.each_char do |char|
438
+ char_width = char.ord > 127 || char.match?(/[あ-ん ア-ン 一-龯]/) ? 2 : 1
439
+
440
+ if current_width + char_width > max_width
441
+ # "..."を追加できるかチェック
442
+ result += '...' if max_width >= 3 && current_width <= max_width - 3
443
+ break
444
+ end
445
+
446
+ result += char
447
+ current_width += char_width
448
+ end
449
+
450
+ result
451
+ end
452
+
453
+ def find_break_point(line, max_width)
454
+ # 最大幅以内で適切な折り返し位置を見つける
455
+ return line.length if display_width(line) <= max_width
456
+
457
+ # 文字ごとに幅を計算しながら適切な位置を探す
458
+ current_width = 0
459
+ best_break_point = 0
460
+ space_break_point = nil
461
+ punct_break_point = nil
462
+
463
+ line.each_char.with_index do |char, index|
464
+ char_width = char.ord > 127 || char.match?(/[あ-ん ア-ン 一-龯]/) ? 2 : 1
465
+
466
+ break if current_width + char_width > max_width
467
+
468
+ current_width += char_width
469
+ best_break_point = index + 1
470
+
471
+ # スペースで区切れる位置を記録
472
+ space_break_point = index + 1 if char == ' ' && current_width > max_width * 0.5
473
+
474
+ # 日本語の句読点で区切れる位置を記録
475
+ punct_break_point = index + 1 if char.match?(/[、。,.!?]/) && current_width > max_width * 0.5
476
+ end
477
+
478
+ # 最適な折り返し位置を選択
479
+ space_break_point || punct_break_point || best_break_point
480
+ end
481
+
482
+ def get_display_entries
483
+ if @keybind_handler.filter_active?
484
+ # Get filtered entries from keybind_handler
485
+ all_entries = @directory_listing.list_entries
486
+ query = @keybind_handler.filter_query.downcase
487
+ query.empty? ? all_entries : all_entries.select { |entry| entry[:name].downcase.include?(query) }
488
+ else
489
+ @directory_listing.list_entries
490
+ end
491
+ end
492
+
493
+ def draw_footer
494
+ # 最下行から1行上に表示してスクロールを避ける
495
+ footer_line = @screen_height - FOOTER_HEIGHT
496
+ print "\e[#{footer_line};1H"
497
+
498
+ if @keybind_handler.filter_active?
499
+ if @keybind_handler.instance_variable_get(:@filter_mode)
500
+ help_text = "Filter mode: Type to filter, ESC to clear, Enter to apply, Backspace to delete"
501
+ else
502
+ help_text = "Filtered view active - Space to edit filter, ESC to clear filter"
503
+ end
504
+ else
505
+ help_text = ConfigLoader.message('help.full')
506
+ help_text = ConfigLoader.message('help.short') if help_text.length > @screen_width
507
+ end
508
+
509
+ # 文字列を確実に画面幅に合わせる
510
+ footer_content = help_text.ljust(@screen_width)[0...@screen_width]
511
+ print "\e[7m#{footer_content}\e[0m"
512
+ end
513
+
514
+ def handle_input
515
+ begin
516
+ input = STDIN.getch
517
+ rescue Errno::ENOTTY, Errno::ENODEV
518
+ # ターミナルでない環境(IDE等)では標準入力を使用
519
+ print "\nOperation: "
520
+ input = STDIN.gets
521
+ return 'q' if input.nil?
522
+ input = input.chomp.downcase
523
+ return input[0] if input.length > 0
524
+
525
+ return 'q'
526
+ end
527
+
528
+ # 特殊キーの処理
529
+ if input == "\e"
530
+ # エスケープシーケンスの処理
531
+ next_char = begin
532
+ STDIN.read_nonblock(1)
533
+ rescue StandardError
534
+ nil
535
+ end
536
+ if next_char == '['
537
+ arrow_key = begin
538
+ STDIN.read_nonblock(1)
539
+ rescue StandardError
540
+ nil
541
+ end
542
+ input = case arrow_key
543
+ when 'A' # 上矢印
544
+ 'k'
545
+ when 'B' # 下矢印
546
+ 'j'
547
+ when 'C' # 右矢印
548
+ 'l'
549
+ when 'D' # 左矢印
550
+ 'h'
551
+ else
552
+ "\e" # ESCキー(そのまま保持)
553
+ end
554
+ else
555
+ input = "\e" # ESCキー(そのまま保持)
556
+ end
557
+ end
558
+
559
+ # コマンドモードがアクティブな場合は、コマンド入力を処理
560
+ if @command_mode_active
561
+ handle_command_input(input)
562
+ return
563
+ end
564
+
565
+ # キーバインドハンドラーに処理を委譲
566
+ result = @keybind_handler.handle_key(input)
567
+
568
+ # 終了処理(qキーのみ)
569
+ if input == 'q'
570
+ @running = false
571
+ end
572
+ end
573
+
574
+ # コマンドモード関連のメソッドは public にする
575
+ public
576
+
577
+ # コマンドモードを起動
578
+ def activate_command_mode
579
+ @command_mode_active = true
580
+ @command_input = ""
581
+ end
582
+
583
+ # コマンドモードを終了
584
+ def deactivate_command_mode
585
+ @command_mode_active = false
586
+ @command_input = ""
587
+ end
588
+
589
+ # コマンドモードがアクティブかどうか
590
+ def command_mode_active?
591
+ @command_mode_active
592
+ end
593
+
594
+ # コマンド入力を処理
595
+ def handle_command_input(input)
596
+ case input
597
+ when "\r", "\n"
598
+ # Enter キーでコマンドを実行
599
+ execute_command(@command_input)
600
+ deactivate_command_mode
601
+ when "\e"
602
+ # Escape キーでコマンドモードをキャンセル
603
+ deactivate_command_mode
604
+ when "\t"
605
+ # Tab キーで補完
606
+ @command_input = @command_mode_ui.complete_command(@command_input)
607
+ when "\u007F", "\b"
608
+ # Backspace
609
+ @command_input.chop! unless @command_input.empty?
610
+ else
611
+ # 通常の文字を追加
612
+ @command_input += input if input.length == 1
613
+ end
614
+ end
615
+
616
+ # コマンドを実行
617
+ def execute_command(command_string)
618
+ return if command_string.nil? || command_string.empty?
619
+
620
+ result = @command_mode.execute(command_string)
621
+
622
+ # コマンド実行結果をフローティングウィンドウで表示
623
+ @command_mode_ui.show_result(result) if result
624
+
625
+ # 画面を再描画
626
+ draw_screen
627
+ end
628
+ end
629
+ end
630
+