beniya 0.3.0 → 0.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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Beniya
4
4
  class KeybindHandler
5
- attr_reader :current_index
5
+ attr_reader :current_index, :filter_query
6
6
 
7
7
  def initialize
8
8
  @current_index = 0
@@ -10,7 +10,7 @@ module Beniya
10
10
  @terminal_ui = nil
11
11
  @file_opener = FileOpener.new
12
12
  @filter_mode = false
13
- @filter_query = ""
13
+ @filter_query = ''
14
14
  @filtered_entries = []
15
15
  @original_entries = []
16
16
  @selected_items = []
@@ -42,9 +42,7 @@ module Beniya
42
42
  return false unless @directory_listing
43
43
 
44
44
  # フィルターモード中は他のキーバインドを無効化
45
- if @filter_mode
46
- return handle_filter_input(key)
47
- end
45
+ return handle_filter_input(key) if @filter_mode
48
46
 
49
47
  case key
50
48
  when 'j'
@@ -53,7 +51,7 @@ module Beniya
53
51
  move_up
54
52
  when 'h'
55
53
  navigate_parent
56
- when 'l', "\r", "\n" # l, Enter
54
+ when 'l', "\r", "\n" # l, Enter
57
55
  navigate_enter
58
56
  when 'g'
59
57
  move_to_top
@@ -74,9 +72,9 @@ module Beniya
74
72
  # 新規フィルターモード開始
75
73
  start_filter_mode
76
74
  end
77
- when ' ' # Space - toggle selection
75
+ when ' ' # Space - toggle selection
78
76
  toggle_selection
79
- when "\e" # ESC
77
+ when "\e" # ESC
80
78
  if !@filter_query.empty?
81
79
  # フィルタが設定されている場合はクリア
82
80
  clear_filter_mode
@@ -103,7 +101,7 @@ module Beniya
103
101
  when 'x' # x - delete selected files
104
102
  delete_selected_files
105
103
  else
106
- false # #{ConfigLoader.message('keybind.invalid_key')}
104
+ false # #{ConfigLoader.message('keybind.invalid_key')}
107
105
  end
108
106
  end
109
107
 
@@ -121,10 +119,6 @@ module Beniya
121
119
  @filter_mode || !@filter_query.empty?
122
120
  end
123
121
 
124
- def filter_query
125
- @filter_query
126
- end
127
-
128
122
  def get_active_entries
129
123
  if @filter_mode || !@filter_query.empty?
130
124
  @filtered_entries.empty? ? [] : @filtered_entries
@@ -161,7 +155,7 @@ module Beniya
161
155
  entry = current_entry
162
156
  return false unless entry
163
157
 
164
- if entry[:type] == "directory"
158
+ if entry[:type] == 'directory'
165
159
  result = @directory_listing.navigate_to(entry[:name])
166
160
  if result
167
161
  @current_index = 0 # select first entry in new directory
@@ -186,7 +180,7 @@ module Beniya
186
180
  def refresh
187
181
  # ウィンドウサイズを更新して画面を再描画
188
182
  @terminal_ui&.refresh_display
189
-
183
+
190
184
  @directory_listing.refresh
191
185
  if @filter_mode || !@filter_query.empty?
192
186
  # Re-apply filter with new directory contents
@@ -203,8 +197,8 @@ module Beniya
203
197
  def open_current_file
204
198
  entry = current_entry
205
199
  return false unless entry
206
-
207
- if entry[:type] == "file"
200
+
201
+ if entry[:type] == 'file'
208
202
  @file_opener.open_file(entry[:path])
209
203
  true
210
204
  else
@@ -219,78 +213,76 @@ module Beniya
219
213
  end
220
214
 
221
215
  def exit_request
222
- true # request exit
216
+ true # request exit
223
217
  end
224
218
 
225
219
  def fzf_search
226
220
  return false unless fzf_available?
227
-
221
+
228
222
  current_path = @directory_listing&.current_path || Dir.pwd
229
-
223
+
230
224
  # fzfでファイル検索を実行
231
225
  selected_file = `cd "#{current_path}" && find . -type f | fzf --preview 'cat {}'`.strip
232
-
226
+
233
227
  # ファイルが選択された場合、そのファイルを開く
234
228
  if !selected_file.empty? && File.exist?(File.join(current_path, selected_file))
235
229
  full_path = File.expand_path(selected_file, current_path)
236
230
  @file_opener.open_file(full_path)
237
231
  end
238
-
232
+
239
233
  true
240
234
  end
241
235
 
242
236
  def fzf_available?
243
- system("which fzf > /dev/null 2>&1")
237
+ system('which fzf > /dev/null 2>&1')
244
238
  end
245
239
 
246
240
  def rga_search
247
241
  return false unless rga_available?
248
-
242
+
249
243
  current_path = @directory_listing&.current_path || Dir.pwd
250
-
244
+
251
245
  # input search keyword
252
246
  print ConfigLoader.message('keybind.search_text')
253
247
  search_query = STDIN.gets.chomp
254
248
  return false if search_query.empty?
255
-
249
+
256
250
  # execute rga file content search
257
251
  search_results = `cd "#{current_path}" && rga --line-number --with-filename "#{search_query}" . 2>/dev/null`
258
-
252
+
259
253
  if search_results.empty?
260
254
  puts "\n#{ConfigLoader.message('keybind.no_matches')}"
261
255
  print ConfigLoader.message('keybind.press_any_key')
262
256
  STDIN.getch
263
257
  return true
264
258
  end
265
-
259
+
266
260
  # pass results to fzf for selection
267
- selected_result = IO.popen("fzf", "r+") do |fzf|
261
+ selected_result = IO.popen('fzf', 'r+') do |fzf|
268
262
  fzf.write(search_results)
269
263
  fzf.close_write
270
264
  fzf.read.strip
271
265
  end
272
-
266
+
273
267
  # extract file path and line number from selected result
274
268
  if !selected_result.empty? && selected_result.match(/^(.+?):(\d+):/)
275
- file_path = $1
276
- line_number = $2.to_i
269
+ file_path = ::Regexp.last_match(1)
270
+ line_number = ::Regexp.last_match(2).to_i
277
271
  full_path = File.expand_path(file_path, current_path)
278
-
279
- if File.exist?(full_path)
280
- @file_opener.open_file_with_line(full_path, line_number)
281
- end
272
+
273
+ @file_opener.open_file_with_line(full_path, line_number) if File.exist?(full_path)
282
274
  end
283
-
275
+
284
276
  true
285
277
  end
286
278
 
287
279
  def rga_available?
288
- system("which rga > /dev/null 2>&1")
280
+ system('which rga > /dev/null 2>&1')
289
281
  end
290
282
 
291
283
  def start_filter_mode
292
284
  @filter_mode = true
293
- @filter_query = ""
285
+ @filter_query = ''
294
286
  @original_entries = @directory_listing.list_entries.dup
295
287
  @filtered_entries = @original_entries.dup
296
288
  @current_index = 0
@@ -299,11 +291,11 @@ module Beniya
299
291
 
300
292
  def handle_filter_input(key)
301
293
  case key
302
- when "\e" # ESC - フィルタをクリアして通常モードに戻る
294
+ when "\e" # ESC - フィルタをクリアして通常モードに戻る
303
295
  clear_filter_mode
304
- when "\r", "\n" # Enter - フィルタを維持して通常モードに戻る
296
+ when "\r", "\n" # Enter - フィルタを維持して通常モードに戻る
305
297
  exit_filter_mode_keep_filter
306
- when "\u007f", "\b" # Backspace
298
+ when "\u007f", "\b" # Backspace
307
299
  if @filter_query.length > 0
308
300
  @filter_query = @filter_query[0...-1]
309
301
  apply_filter
@@ -312,10 +304,10 @@ module Beniya
312
304
  end
313
305
  else
314
306
  # printable characters (英数字、記号、日本語文字など)
315
- if key.length == 1 && key.ord >= 32 && key.ord < 127 # ASCII printable
307
+ if key.length == 1 && key.ord >= 32 && key.ord < 127 # ASCII printable
316
308
  @filter_query += key
317
309
  apply_filter
318
- elsif key.bytesize > 1 # Multi-byte characters (Japanese, etc.)
310
+ elsif key.bytesize > 1 # Multi-byte characters (Japanese, etc.)
319
311
  @filter_query += key
320
312
  apply_filter
321
313
  end
@@ -345,7 +337,7 @@ module Beniya
345
337
  def clear_filter_mode
346
338
  # フィルタをクリアして通常モードに戻る
347
339
  @filter_mode = false
348
- @filter_query = ""
340
+ @filter_query = ''
349
341
  @filtered_entries = []
350
342
  @original_entries = []
351
343
  @current_index = 0
@@ -358,12 +350,12 @@ module Beniya
358
350
 
359
351
  def create_file
360
352
  current_path = @directory_listing&.current_path || Dir.pwd
361
-
353
+
362
354
  # ファイル名の入力を求める
363
355
  print ConfigLoader.message('keybind.input_filename')
364
356
  filename = STDIN.gets.chomp
365
357
  return false if filename.empty?
366
-
358
+
367
359
  # 不正なファイル名のチェック
368
360
  if filename.include?('/') || filename.include?('\\')
369
361
  puts "\n#{ConfigLoader.message('keybind.invalid_filename')}"
@@ -371,9 +363,9 @@ module Beniya
371
363
  STDIN.getch
372
364
  return false
373
365
  end
374
-
366
+
375
367
  file_path = File.join(current_path, filename)
376
-
368
+
377
369
  # ファイルが既に存在する場合の確認
378
370
  if File.exist?(file_path)
379
371
  puts "\n#{ConfigLoader.message('keybind.file_exists')}"
@@ -381,24 +373,24 @@ module Beniya
381
373
  STDIN.getch
382
374
  return false
383
375
  end
384
-
376
+
385
377
  begin
386
378
  # ファイルを作成
387
379
  File.write(file_path, '')
388
-
380
+
389
381
  # ディレクトリ表示を更新
390
382
  @directory_listing.refresh
391
-
383
+
392
384
  # 作成したファイルを選択状態にする
393
385
  entries = @directory_listing.list_entries
394
386
  new_file_index = entries.find_index { |entry| entry[:name] == filename }
395
387
  @current_index = new_file_index if new_file_index
396
-
388
+
397
389
  puts "\n#{ConfigLoader.message('keybind.file_created')}: #{filename}"
398
390
  print ConfigLoader.message('keybind.press_any_key')
399
391
  STDIN.getch
400
392
  true
401
- rescue => e
393
+ rescue StandardError => e
402
394
  puts "\n#{ConfigLoader.message('keybind.creation_error')}: #{e.message}"
403
395
  print ConfigLoader.message('keybind.press_any_key')
404
396
  STDIN.getch
@@ -408,12 +400,12 @@ module Beniya
408
400
 
409
401
  def create_directory
410
402
  current_path = @directory_listing&.current_path || Dir.pwd
411
-
403
+
412
404
  # ディレクトリ名の入力を求める
413
405
  print ConfigLoader.message('keybind.input_dirname')
414
406
  dirname = STDIN.gets.chomp
415
407
  return false if dirname.empty?
416
-
408
+
417
409
  # 不正なディレクトリ名のチェック
418
410
  if dirname.include?('/') || dirname.include?('\\')
419
411
  puts "\n#{ConfigLoader.message('keybind.invalid_dirname')}"
@@ -421,9 +413,9 @@ module Beniya
421
413
  STDIN.getch
422
414
  return false
423
415
  end
424
-
416
+
425
417
  dir_path = File.join(current_path, dirname)
426
-
418
+
427
419
  # ディレクトリが既に存在する場合の確認
428
420
  if File.exist?(dir_path)
429
421
  puts "\n#{ConfigLoader.message('keybind.directory_exists')}"
@@ -431,24 +423,24 @@ module Beniya
431
423
  STDIN.getch
432
424
  return false
433
425
  end
434
-
426
+
435
427
  begin
436
428
  # ディレクトリを作成
437
429
  Dir.mkdir(dir_path)
438
-
430
+
439
431
  # ディレクトリ表示を更新
440
432
  @directory_listing.refresh
441
-
433
+
442
434
  # 作成したディレクトリを選択状態にする
443
435
  entries = @directory_listing.list_entries
444
436
  new_dir_index = entries.find_index { |entry| entry[:name] == dirname }
445
437
  @current_index = new_dir_index if new_dir_index
446
-
438
+
447
439
  puts "\n#{ConfigLoader.message('keybind.directory_created')}: #{dirname}"
448
440
  print ConfigLoader.message('keybind.press_any_key')
449
441
  STDIN.getch
450
442
  true
451
- rescue => e
443
+ rescue StandardError => e
452
444
  puts "\n#{ConfigLoader.message('keybind.creation_error')}: #{e.message}"
453
445
  print ConfigLoader.message('keybind.press_any_key')
454
446
  STDIN.getch
@@ -471,7 +463,7 @@ module Beniya
471
463
  def move_selected_to_base
472
464
  return false if @selected_items.empty? || @base_directory.nil?
473
465
 
474
- if show_confirmation_dialog("移動", @selected_items.length)
466
+ if show_confirmation_dialog('Move', @selected_items.length)
475
467
  perform_file_operation(:move, @selected_items, @base_directory)
476
468
  else
477
469
  false
@@ -481,7 +473,7 @@ module Beniya
481
473
  def copy_selected_to_base
482
474
  return false if @selected_items.empty? || @base_directory.nil?
483
475
 
484
- if show_confirmation_dialog("コピー", @selected_items.length)
476
+ if show_confirmation_dialog('Copy', @selected_items.length)
485
477
  perform_file_operation(:copy, @selected_items, @base_directory)
486
478
  else
487
479
  false
@@ -489,9 +481,9 @@ module Beniya
489
481
  end
490
482
 
491
483
  def show_confirmation_dialog(operation, count)
492
- print "\n#{count}個のアイテムを#{operation}しますか? (y/n): "
484
+ print "\n#{operation} #{count} item(s)? (y/n): "
493
485
  response = STDIN.gets.chomp.downcase
494
- response == 'y' || response == 'yes'
486
+ %w[y yes].include?(response)
495
487
  end
496
488
 
497
489
  def perform_file_operation(operation, items, destination)
@@ -506,13 +498,13 @@ module Beniya
506
498
  case operation
507
499
  when :move
508
500
  if File.exist?(dest_path)
509
- puts "\n#{item_name} は既に移動先に存在します。スキップします。"
501
+ puts "\n#{item_name} already exists in destination. Skipping."
510
502
  next
511
503
  end
512
504
  FileUtils.mv(source_path, dest_path)
513
505
  when :copy
514
506
  if File.exist?(dest_path)
515
- puts "\n#{item_name} は既に移動先に存在します。スキップします。"
507
+ puts "\n#{item_name} already exists in destination. Skipping."
516
508
  next
517
509
  end
518
510
  if File.directory?(source_path)
@@ -522,17 +514,17 @@ module Beniya
522
514
  end
523
515
  end
524
516
  success_count += 1
525
- rescue => e
526
- puts "\n#{item_name} の#{operation == :move ? '移動' : 'コピー'}に失敗: #{e.message}"
517
+ rescue StandardError => e
518
+ puts "\nFailed to #{operation == :move ? 'move' : 'copy'} #{item_name}: #{e.message}"
527
519
  end
528
520
  end
529
521
 
530
522
  # 操作完了後の処理
531
523
  @selected_items.clear
532
524
  @directory_listing.refresh if @directory_listing
533
-
534
- puts "\n#{success_count}個のアイテムを#{operation == :move ? '移動' : 'コピー'}しました。"
535
- print "何かキーを押してください..."
525
+
526
+ puts "\n#{operation == :move ? 'Moved' : 'Copied'} #{success_count} item(s)."
527
+ print 'Press any key to continue...'
536
528
  STDIN.getch
537
529
  true
538
530
  end
@@ -548,39 +540,337 @@ module Beniya
548
540
  end
549
541
 
550
542
  def show_delete_confirmation(count)
551
- print "\n#{count}個のアイテムを削除しますか? (y/n): "
552
- response = STDIN.gets.chomp.downcase
553
- response == 'y' || response == 'yes'
543
+ show_floating_delete_confirmation(count)
544
+ end
545
+
546
+ def show_floating_delete_confirmation(count)
547
+ # コンテンツの準備
548
+ title = 'Delete Confirmation'
549
+ content_lines = [
550
+ '',
551
+ "Delete #{count} item(s)?",
552
+ '',
553
+ ' [Y]es - Delete',
554
+ ' [N]o - Cancel',
555
+ ''
556
+ ]
557
+
558
+ # ダイアログのサイズ設定(コンテンツに合わせて調整)
559
+ dialog_width = 45
560
+ # タイトルあり: 上枠1 + タイトル1 + 区切り1 + コンテンツ6 + 下枠1 = 10
561
+ dialog_height = 4 + content_lines.length
562
+
563
+ # ダイアログの位置を中央に設定
564
+ x, y = get_screen_center(dialog_width, dialog_height)
565
+
566
+ # ダイアログの描画
567
+ draw_floating_window(x, y, dialog_width, dialog_height, title, content_lines, {
568
+ border_color: "\e[31m", # 赤色(警告)
569
+ title_color: "\e[1;31m", # 太字赤色
570
+ content_color: "\e[37m" # 白色
571
+ })
572
+
573
+ # フラッシュしてユーザーの注意を引く
574
+ print "\a" # ベル音
575
+
576
+ # キー入力待機
577
+ loop do
578
+ input = STDIN.getch.downcase
579
+
580
+ case input
581
+ when 'y'
582
+ # ダイアログをクリア
583
+ clear_floating_window_area(x, y, dialog_width, dialog_height)
584
+ @terminal_ui&.refresh_display # 画面を再描画
585
+ return true
586
+ when 'n', "\e", "\x03" # n, ESC, Ctrl+C
587
+ # ダイアログをクリア
588
+ clear_floating_window_area(x, y, dialog_width, dialog_height)
589
+ @terminal_ui&.refresh_display # 画面を再描画
590
+ return false
591
+ when 'q' # qキーでもキャンセル
592
+ clear_floating_window_area(x, y, dialog_width, dialog_height)
593
+ @terminal_ui&.refresh_display
594
+ return false
595
+ end
596
+ # 無効なキー入力の場合は再度ループ
597
+ end
554
598
  end
555
599
 
556
600
  def perform_delete_operation(items)
557
601
  success_count = 0
602
+ error_messages = []
558
603
  current_path = @directory_listing&.current_path || Dir.pwd
604
+ debug_log = []
559
605
 
560
606
  items.each do |item_name|
561
607
  item_path = File.join(current_path, item_name)
608
+ debug_log << "Processing: #{item_name}"
562
609
 
563
610
  begin
564
- if File.directory?(item_path)
611
+ # ファイル/ディレクトリの存在確認
612
+ unless File.exist?(item_path)
613
+ error_messages << "#{item_name}: File not found"
614
+ debug_log << ' Error: File not found'
615
+ next
616
+ end
617
+
618
+ debug_log << ' Existence check: OK'
619
+ is_directory = File.directory?(item_path)
620
+ debug_log << " Type: #{is_directory ? 'Directory' : 'File'}"
621
+
622
+ if is_directory
565
623
  FileUtils.rm_rf(item_path)
624
+ debug_log << ' FileUtils.rm_rf executed'
566
625
  else
567
626
  FileUtils.rm(item_path)
627
+ debug_log << ' FileUtils.rm executed'
568
628
  end
569
- success_count += 1
570
- puts "\n#{item_name} を削除しました。"
571
- rescue => e
572
- puts "\n#{item_name} の削除に失敗: #{e.message}"
629
+
630
+ # 削除が実際に成功したかを確認
631
+ sleep(0.01) # 10ms待機してファイルシステムの同期を待つ
632
+ still_exists = File.exist?(item_path)
633
+ debug_log << " Post-deletion check: #{still_exists}"
634
+
635
+ if still_exists
636
+ error_messages << "#{item_name}: Deletion failed"
637
+ debug_log << ' Result: Failed'
638
+ else
639
+ success_count += 1
640
+ debug_log << ' Result: Success'
641
+ end
642
+ rescue StandardError => e
643
+ error_messages << "#{item_name}: #{e.message}"
644
+ debug_log << " Exception: #{e.message}"
573
645
  end
574
646
  end
575
647
 
648
+ # デバッグログをファイルに出力(開発時のみ)
649
+ if ENV['BENIYA_DEBUG'] == '1'
650
+ debug_file = File.join(Dir.home, '.beniya_delete_debug.log')
651
+ File.open(debug_file, 'a') do |f|
652
+ f.puts "=== Delete Process Debug #{Time.now} ==="
653
+ f.puts "Target directory: #{current_path}"
654
+ f.puts "Target items: #{items.inspect}"
655
+ debug_log.each { |line| f.puts line }
656
+ f.puts "Final result: #{success_count} successful, #{items.length - success_count} failed"
657
+ f.puts "Error messages: #{error_messages.inspect}"
658
+ f.puts ''
659
+ end
660
+ end
661
+
662
+
663
+ # デバッグ用:削除結果の値をログファイルに出力
664
+ result_debug_file = File.join(Dir.home, '.beniya_result_debug.log')
665
+ File.open(result_debug_file, 'a') do |f|
666
+ f.puts "=== Delete Result Debug #{Time.now} ==="
667
+ f.puts "success_count: #{success_count}"
668
+ f.puts "total_count: #{items.length}"
669
+ f.puts "error_messages.length: #{error_messages.length}"
670
+ f.puts "has_errors: #{!error_messages.empty?}"
671
+ f.puts "condition check: success_count == total_count && !has_errors = #{success_count == items.length && error_messages.empty?}"
672
+ f.puts ""
673
+ end
674
+
675
+ # 削除結果をフローティングウィンドウで表示
676
+ show_deletion_result(success_count, items.length, error_messages)
677
+
576
678
  # 削除完了後の処理
577
679
  @selected_items.clear
578
680
  @directory_listing.refresh if @directory_listing
579
-
580
- puts "\n#{success_count}個のアイテムを削除しました。"
581
- print "何かキーを押してください..."
582
- STDIN.getch
681
+
583
682
  true
584
683
  end
684
+
685
+ def show_deletion_result(success_count, total_count, error_messages = [])
686
+ # 詳細デバッグログを出力
687
+ detailed_debug_file = File.join(Dir.home, '.beniya_detailed_debug.log')
688
+ File.open(detailed_debug_file, 'a') do |f|
689
+ f.puts "=== show_deletion_result called #{Time.now} ==="
690
+ f.puts "Arguments: success_count=#{success_count}, total_count=#{total_count}"
691
+ f.puts "error_messages: #{error_messages.inspect}"
692
+ f.puts "error_messages.empty?: #{error_messages.empty?}"
693
+ f.puts ""
694
+ end
695
+
696
+ # エラーメッセージがある場合はダイアログサイズを拡大
697
+ has_errors = !error_messages.empty?
698
+ dialog_width = has_errors ? 50 : 35
699
+ dialog_height = has_errors ? [8 + error_messages.length, 15].min : 6
700
+
701
+ # ダイアログの位置を中央に設定
702
+ x, y = get_screen_center(dialog_width, dialog_height)
703
+
704
+ # 成功・失敗に応じた色設定
705
+ # デバッグ: success_count == total_count かつ has_errors が false の場合のみ成功扱い
706
+ if success_count == total_count && !has_errors
707
+ border_color = "\e[32m" # 緑色(成功)
708
+ title_color = "\e[1;32m" # 太字緑色
709
+ title = 'Delete Complete'
710
+ message = "Deleted #{success_count} item(s)"
711
+ else
712
+ border_color = "\e[33m" # 黄色(警告)
713
+ title_color = "\e[1;33m" # 太字黄色
714
+ title = 'Delete Result'
715
+ if success_count == total_count && has_errors
716
+ # 全て削除成功したがエラーメッセージがある場合(本来ここに入らないはず)
717
+ message = "#{success_count} deleted (with error info)"
718
+ else
719
+ failed_count = total_count - success_count
720
+ message = "#{success_count} deleted, #{failed_count} failed"
721
+ end
722
+ end
723
+
724
+ # コンテンツの準備
725
+ content_lines = ['', message]
726
+
727
+ # デバッグ情報を追加(開発中のみ)
728
+ content_lines << ""
729
+ content_lines << "DEBUG: success=#{success_count}, total=#{total_count}, errors=#{error_messages.length}"
730
+
731
+ # エラーメッセージがある場合は追加
732
+ if has_errors
733
+ content_lines << ''
734
+ content_lines << 'Error details:'
735
+ error_messages.each { |error| content_lines << " #{error}" }
736
+ end
737
+
738
+ content_lines << ''
739
+ content_lines << 'Press any key to continue...'
740
+
741
+ # ダイアログの描画
742
+ draw_floating_window(x, y, dialog_width, dialog_height, title, content_lines, {
743
+ border_color: border_color,
744
+ title_color: title_color,
745
+ content_color: "\e[37m"
746
+ })
747
+
748
+ # キー入力待機
749
+ STDIN.getch
750
+
751
+ # ダイアログをクリア
752
+ clear_floating_window_area(x, y, dialog_width, dialog_height)
753
+ @terminal_ui&.refresh_display
754
+ end
755
+
756
+ # フローティングウィンドウの基盤メソッド
757
+ def draw_floating_window(x, y, width, height, title, content_lines, options = {})
758
+ # デフォルトオプション
759
+ border_color = options[:border_color] || "\e[37m" # 白色
760
+ title_color = options[:title_color] || "\e[1;33m" # 黄色(太字)
761
+ content_color = options[:content_color] || "\e[37m" # 白色
762
+ reset_color = "\e[0m"
763
+
764
+ # ウィンドウの描画
765
+ # 上辺
766
+ print "\e[#{y};#{x}H#{border_color}┌#{'─' * (width - 2)}┐#{reset_color}"
767
+
768
+ # タイトル行
769
+ if title
770
+ title_width = display_width(title)
771
+ title_padding = (width - 2 - title_width) / 2
772
+ padded_title = ' ' * title_padding + title
773
+ title_line = pad_string_to_width(padded_title, width - 2)
774
+ print "\e[#{y + 1};#{x}H#{border_color}│#{title_color}#{title_line}#{border_color}│#{reset_color}"
775
+
776
+ # タイトル区切り線
777
+ print "\e[#{y + 2};#{x}H#{border_color}├#{'─' * (width - 2)}┤#{reset_color}"
778
+ content_start_y = y + 3
779
+ else
780
+ content_start_y = y + 1
781
+ end
782
+
783
+ # コンテンツ行
784
+ content_height = title ? height - 4 : height - 2
785
+ content_lines.each_with_index do |line, index|
786
+ break if index >= content_height
787
+
788
+ line_y = content_start_y + index
789
+ line_content = pad_string_to_width(line, width - 2) # 正確な幅でパディング
790
+ print "\e[#{line_y};#{x}H#{border_color}│#{content_color}#{line_content}#{border_color}│#{reset_color}"
791
+ end
792
+
793
+ # 空行を埋める
794
+ remaining_lines = content_height - content_lines.length
795
+ remaining_lines.times do |i|
796
+ line_y = content_start_y + content_lines.length + i
797
+ empty_line = ' ' * (width - 2)
798
+ print "\e[#{line_y};#{x}H#{border_color}│#{empty_line}│#{reset_color}"
799
+ end
800
+
801
+ # 下辺
802
+ bottom_y = y + height - 1
803
+ print "\e[#{bottom_y};#{x}H#{border_color}└#{'─' * (width - 2)}┘#{reset_color}"
804
+ end
805
+
806
+ def display_width(str)
807
+ # 日本語文字の幅を考慮した文字列幅の計算
808
+ # Unicode East Asian Width プロパティを考慮
809
+ str.each_char.map do |char|
810
+ case char
811
+ when /[\u3000-\u303F\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF\uFF00-\uFFEF]/
812
+ # 日本語の文字(ひらがな、カタカナ、漢字、全角記号)
813
+ 2
814
+ when /[\u0020-\u007E]/
815
+ # ASCII文字
816
+ 1
817
+ else
818
+ # その他の文字はバイト数で判断
819
+ char.bytesize > 1 ? 2 : 1
820
+ end
821
+ end.sum
822
+ end
823
+
824
+ def pad_string_to_width(str, target_width)
825
+ # 文字列を指定した表示幅になるようにパディング
826
+ current_width = display_width(str)
827
+ if current_width >= target_width
828
+ # 文字列が長すぎる場合は切り詰め
829
+ truncate_to_width(str, target_width)
830
+ else
831
+ # 不足分をスペースで埋める
832
+ str + ' ' * (target_width - current_width)
833
+ end
834
+ end
835
+
836
+ def truncate_to_width(str, max_width)
837
+ # 指定した表示幅に収まるように文字列を切り詰め
838
+ result = ''
839
+ current_width = 0
840
+
841
+ str.each_char do |char|
842
+ char_width = display_width(char)
843
+ break if current_width + char_width > max_width
844
+
845
+ result += char
846
+ current_width += char_width
847
+ end
848
+
849
+ result
850
+ end
851
+
852
+ def get_screen_center(content_width, content_height)
853
+ # ターミナルのサイズを取得
854
+ console = IO.console
855
+ if console
856
+ screen_width, screen_height = console.winsize.reverse
857
+ else
858
+ screen_width = 80
859
+ screen_height = 24
860
+ end
861
+
862
+ # 中央位置を計算
863
+ x = [(screen_width - content_width) / 2, 1].max
864
+ y = [(screen_height - content_height) / 2, 1].max
865
+
866
+ [x, y]
867
+ end
868
+
869
+ def clear_floating_window_area(x, y, width, height)
870
+ # フローティングウィンドウの領域をクリア
871
+ height.times do |row|
872
+ print "\e[#{y + row};#{x}H#{' ' * width}"
873
+ end
874
+ end
585
875
  end
586
876
  end