beniya 0.2.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,9 +10,11 @@ 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
+ @selected_items = []
17
+ @base_directory = nil
16
18
  end
17
19
 
18
20
  def set_directory_listing(directory_listing)
@@ -24,13 +26,23 @@ module Beniya
24
26
  @terminal_ui = terminal_ui
25
27
  end
26
28
 
29
+ def set_base_directory(base_dir)
30
+ @base_directory = File.expand_path(base_dir)
31
+ end
32
+
33
+ def selected_items
34
+ @selected_items.dup
35
+ end
36
+
37
+ def is_selected?(entry_name)
38
+ @selected_items.include?(entry_name)
39
+ end
40
+
27
41
  def handle_key(key)
28
42
  return false unless @directory_listing
29
43
 
30
44
  # フィルターモード中は他のキーバインドを無効化
31
- if @filter_mode
32
- return handle_filter_input(key)
33
- end
45
+ return handle_filter_input(key) if @filter_mode
34
46
 
35
47
  case key
36
48
  when 'j'
@@ -39,7 +51,7 @@ module Beniya
39
51
  move_up
40
52
  when 'h'
41
53
  navigate_parent
42
- when 'l', "\r", "\n" # l, Enter
54
+ when 'l', "\r", "\n" # l, Enter
43
55
  navigate_enter
44
56
  when 'g'
45
57
  move_to_top
@@ -60,9 +72,9 @@ module Beniya
60
72
  # 新規フィルターモード開始
61
73
  start_filter_mode
62
74
  end
63
- when ' ' # Space - disabled for future functionality
64
- false
65
- when "\e" # ESC
75
+ when ' ' # Space - toggle selection
76
+ toggle_selection
77
+ when "\e" # ESC
66
78
  if !@filter_query.empty?
67
79
  # フィルタが設定されている場合はクリア
68
80
  clear_filter_mode
@@ -82,8 +94,14 @@ module Beniya
82
94
  create_file
83
95
  when 'A' # A
84
96
  create_directory
97
+ when 'm' # m - move selected files to base directory
98
+ move_selected_to_base
99
+ when 'p' # p - copy selected files to base directory
100
+ copy_selected_to_base
101
+ when 'x' # x - delete selected files
102
+ delete_selected_files
85
103
  else
86
- false # #{ConfigLoader.message('keybind.invalid_key')}
104
+ false # #{ConfigLoader.message('keybind.invalid_key')}
87
105
  end
88
106
  end
89
107
 
@@ -101,10 +119,6 @@ module Beniya
101
119
  @filter_mode || !@filter_query.empty?
102
120
  end
103
121
 
104
- def filter_query
105
- @filter_query
106
- end
107
-
108
122
  def get_active_entries
109
123
  if @filter_mode || !@filter_query.empty?
110
124
  @filtered_entries.empty? ? [] : @filtered_entries
@@ -141,7 +155,7 @@ module Beniya
141
155
  entry = current_entry
142
156
  return false unless entry
143
157
 
144
- if entry[:type] == "directory"
158
+ if entry[:type] == 'directory'
145
159
  result = @directory_listing.navigate_to(entry[:name])
146
160
  if result
147
161
  @current_index = 0 # select first entry in new directory
@@ -166,7 +180,7 @@ module Beniya
166
180
  def refresh
167
181
  # ウィンドウサイズを更新して画面を再描画
168
182
  @terminal_ui&.refresh_display
169
-
183
+
170
184
  @directory_listing.refresh
171
185
  if @filter_mode || !@filter_query.empty?
172
186
  # Re-apply filter with new directory contents
@@ -183,8 +197,8 @@ module Beniya
183
197
  def open_current_file
184
198
  entry = current_entry
185
199
  return false unless entry
186
-
187
- if entry[:type] == "file"
200
+
201
+ if entry[:type] == 'file'
188
202
  @file_opener.open_file(entry[:path])
189
203
  true
190
204
  else
@@ -199,78 +213,76 @@ module Beniya
199
213
  end
200
214
 
201
215
  def exit_request
202
- true # request exit
216
+ true # request exit
203
217
  end
204
218
 
205
219
  def fzf_search
206
220
  return false unless fzf_available?
207
-
221
+
208
222
  current_path = @directory_listing&.current_path || Dir.pwd
209
-
223
+
210
224
  # fzfでファイル検索を実行
211
225
  selected_file = `cd "#{current_path}" && find . -type f | fzf --preview 'cat {}'`.strip
212
-
226
+
213
227
  # ファイルが選択された場合、そのファイルを開く
214
228
  if !selected_file.empty? && File.exist?(File.join(current_path, selected_file))
215
229
  full_path = File.expand_path(selected_file, current_path)
216
230
  @file_opener.open_file(full_path)
217
231
  end
218
-
232
+
219
233
  true
220
234
  end
221
235
 
222
236
  def fzf_available?
223
- system("which fzf > /dev/null 2>&1")
237
+ system('which fzf > /dev/null 2>&1')
224
238
  end
225
239
 
226
240
  def rga_search
227
241
  return false unless rga_available?
228
-
242
+
229
243
  current_path = @directory_listing&.current_path || Dir.pwd
230
-
244
+
231
245
  # input search keyword
232
246
  print ConfigLoader.message('keybind.search_text')
233
247
  search_query = STDIN.gets.chomp
234
248
  return false if search_query.empty?
235
-
249
+
236
250
  # execute rga file content search
237
251
  search_results = `cd "#{current_path}" && rga --line-number --with-filename "#{search_query}" . 2>/dev/null`
238
-
252
+
239
253
  if search_results.empty?
240
254
  puts "\n#{ConfigLoader.message('keybind.no_matches')}"
241
255
  print ConfigLoader.message('keybind.press_any_key')
242
256
  STDIN.getch
243
257
  return true
244
258
  end
245
-
259
+
246
260
  # pass results to fzf for selection
247
- selected_result = IO.popen("fzf", "r+") do |fzf|
261
+ selected_result = IO.popen('fzf', 'r+') do |fzf|
248
262
  fzf.write(search_results)
249
263
  fzf.close_write
250
264
  fzf.read.strip
251
265
  end
252
-
266
+
253
267
  # extract file path and line number from selected result
254
268
  if !selected_result.empty? && selected_result.match(/^(.+?):(\d+):/)
255
- file_path = $1
256
- line_number = $2.to_i
269
+ file_path = ::Regexp.last_match(1)
270
+ line_number = ::Regexp.last_match(2).to_i
257
271
  full_path = File.expand_path(file_path, current_path)
258
-
259
- if File.exist?(full_path)
260
- @file_opener.open_file_with_line(full_path, line_number)
261
- end
272
+
273
+ @file_opener.open_file_with_line(full_path, line_number) if File.exist?(full_path)
262
274
  end
263
-
275
+
264
276
  true
265
277
  end
266
278
 
267
279
  def rga_available?
268
- system("which rga > /dev/null 2>&1")
280
+ system('which rga > /dev/null 2>&1')
269
281
  end
270
282
 
271
283
  def start_filter_mode
272
284
  @filter_mode = true
273
- @filter_query = ""
285
+ @filter_query = ''
274
286
  @original_entries = @directory_listing.list_entries.dup
275
287
  @filtered_entries = @original_entries.dup
276
288
  @current_index = 0
@@ -279,11 +291,11 @@ module Beniya
279
291
 
280
292
  def handle_filter_input(key)
281
293
  case key
282
- when "\e" # ESC - フィルタをクリアして通常モードに戻る
294
+ when "\e" # ESC - フィルタをクリアして通常モードに戻る
283
295
  clear_filter_mode
284
- when "\r", "\n" # Enter - フィルタを維持して通常モードに戻る
296
+ when "\r", "\n" # Enter - フィルタを維持して通常モードに戻る
285
297
  exit_filter_mode_keep_filter
286
- when "\u007f", "\b" # Backspace
298
+ when "\u007f", "\b" # Backspace
287
299
  if @filter_query.length > 0
288
300
  @filter_query = @filter_query[0...-1]
289
301
  apply_filter
@@ -292,10 +304,10 @@ module Beniya
292
304
  end
293
305
  else
294
306
  # printable characters (英数字、記号、日本語文字など)
295
- 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
296
308
  @filter_query += key
297
309
  apply_filter
298
- elsif key.bytesize > 1 # Multi-byte characters (Japanese, etc.)
310
+ elsif key.bytesize > 1 # Multi-byte characters (Japanese, etc.)
299
311
  @filter_query += key
300
312
  apply_filter
301
313
  end
@@ -325,7 +337,7 @@ module Beniya
325
337
  def clear_filter_mode
326
338
  # フィルタをクリアして通常モードに戻る
327
339
  @filter_mode = false
328
- @filter_query = ""
340
+ @filter_query = ''
329
341
  @filtered_entries = []
330
342
  @original_entries = []
331
343
  @current_index = 0
@@ -338,12 +350,12 @@ module Beniya
338
350
 
339
351
  def create_file
340
352
  current_path = @directory_listing&.current_path || Dir.pwd
341
-
353
+
342
354
  # ファイル名の入力を求める
343
355
  print ConfigLoader.message('keybind.input_filename')
344
356
  filename = STDIN.gets.chomp
345
357
  return false if filename.empty?
346
-
358
+
347
359
  # 不正なファイル名のチェック
348
360
  if filename.include?('/') || filename.include?('\\')
349
361
  puts "\n#{ConfigLoader.message('keybind.invalid_filename')}"
@@ -351,9 +363,9 @@ module Beniya
351
363
  STDIN.getch
352
364
  return false
353
365
  end
354
-
366
+
355
367
  file_path = File.join(current_path, filename)
356
-
368
+
357
369
  # ファイルが既に存在する場合の確認
358
370
  if File.exist?(file_path)
359
371
  puts "\n#{ConfigLoader.message('keybind.file_exists')}"
@@ -361,24 +373,24 @@ module Beniya
361
373
  STDIN.getch
362
374
  return false
363
375
  end
364
-
376
+
365
377
  begin
366
378
  # ファイルを作成
367
379
  File.write(file_path, '')
368
-
380
+
369
381
  # ディレクトリ表示を更新
370
382
  @directory_listing.refresh
371
-
383
+
372
384
  # 作成したファイルを選択状態にする
373
385
  entries = @directory_listing.list_entries
374
386
  new_file_index = entries.find_index { |entry| entry[:name] == filename }
375
387
  @current_index = new_file_index if new_file_index
376
-
388
+
377
389
  puts "\n#{ConfigLoader.message('keybind.file_created')}: #{filename}"
378
390
  print ConfigLoader.message('keybind.press_any_key')
379
391
  STDIN.getch
380
392
  true
381
- rescue => e
393
+ rescue StandardError => e
382
394
  puts "\n#{ConfigLoader.message('keybind.creation_error')}: #{e.message}"
383
395
  print ConfigLoader.message('keybind.press_any_key')
384
396
  STDIN.getch
@@ -388,12 +400,12 @@ module Beniya
388
400
 
389
401
  def create_directory
390
402
  current_path = @directory_listing&.current_path || Dir.pwd
391
-
403
+
392
404
  # ディレクトリ名の入力を求める
393
405
  print ConfigLoader.message('keybind.input_dirname')
394
406
  dirname = STDIN.gets.chomp
395
407
  return false if dirname.empty?
396
-
408
+
397
409
  # 不正なディレクトリ名のチェック
398
410
  if dirname.include?('/') || dirname.include?('\\')
399
411
  puts "\n#{ConfigLoader.message('keybind.invalid_dirname')}"
@@ -401,9 +413,9 @@ module Beniya
401
413
  STDIN.getch
402
414
  return false
403
415
  end
404
-
416
+
405
417
  dir_path = File.join(current_path, dirname)
406
-
418
+
407
419
  # ディレクトリが既に存在する場合の確認
408
420
  if File.exist?(dir_path)
409
421
  puts "\n#{ConfigLoader.message('keybind.directory_exists')}"
@@ -411,29 +423,454 @@ module Beniya
411
423
  STDIN.getch
412
424
  return false
413
425
  end
414
-
426
+
415
427
  begin
416
428
  # ディレクトリを作成
417
429
  Dir.mkdir(dir_path)
418
-
430
+
419
431
  # ディレクトリ表示を更新
420
432
  @directory_listing.refresh
421
-
433
+
422
434
  # 作成したディレクトリを選択状態にする
423
435
  entries = @directory_listing.list_entries
424
436
  new_dir_index = entries.find_index { |entry| entry[:name] == dirname }
425
437
  @current_index = new_dir_index if new_dir_index
426
-
438
+
427
439
  puts "\n#{ConfigLoader.message('keybind.directory_created')}: #{dirname}"
428
440
  print ConfigLoader.message('keybind.press_any_key')
429
441
  STDIN.getch
430
442
  true
431
- rescue => e
443
+ rescue StandardError => e
432
444
  puts "\n#{ConfigLoader.message('keybind.creation_error')}: #{e.message}"
433
445
  print ConfigLoader.message('keybind.press_any_key')
434
446
  STDIN.getch
435
447
  false
436
448
  end
437
449
  end
450
+
451
+ def toggle_selection
452
+ entry = current_entry
453
+ return false unless entry
454
+
455
+ if @selected_items.include?(entry[:name])
456
+ @selected_items.delete(entry[:name])
457
+ else
458
+ @selected_items << entry[:name]
459
+ end
460
+ true
461
+ end
462
+
463
+ def move_selected_to_base
464
+ return false if @selected_items.empty? || @base_directory.nil?
465
+
466
+ if show_confirmation_dialog('Move', @selected_items.length)
467
+ perform_file_operation(:move, @selected_items, @base_directory)
468
+ else
469
+ false
470
+ end
471
+ end
472
+
473
+ def copy_selected_to_base
474
+ return false if @selected_items.empty? || @base_directory.nil?
475
+
476
+ if show_confirmation_dialog('Copy', @selected_items.length)
477
+ perform_file_operation(:copy, @selected_items, @base_directory)
478
+ else
479
+ false
480
+ end
481
+ end
482
+
483
+ def show_confirmation_dialog(operation, count)
484
+ print "\n#{operation} #{count} item(s)? (y/n): "
485
+ response = STDIN.gets.chomp.downcase
486
+ %w[y yes].include?(response)
487
+ end
488
+
489
+ def perform_file_operation(operation, items, destination)
490
+ success_count = 0
491
+ current_path = @directory_listing&.current_path || Dir.pwd
492
+
493
+ items.each do |item_name|
494
+ source_path = File.join(current_path, item_name)
495
+ dest_path = File.join(destination, item_name)
496
+
497
+ begin
498
+ case operation
499
+ when :move
500
+ if File.exist?(dest_path)
501
+ puts "\n#{item_name} already exists in destination. Skipping."
502
+ next
503
+ end
504
+ FileUtils.mv(source_path, dest_path)
505
+ when :copy
506
+ if File.exist?(dest_path)
507
+ puts "\n#{item_name} already exists in destination. Skipping."
508
+ next
509
+ end
510
+ if File.directory?(source_path)
511
+ FileUtils.cp_r(source_path, dest_path)
512
+ else
513
+ FileUtils.cp(source_path, dest_path)
514
+ end
515
+ end
516
+ success_count += 1
517
+ rescue StandardError => e
518
+ puts "\nFailed to #{operation == :move ? 'move' : 'copy'} #{item_name}: #{e.message}"
519
+ end
520
+ end
521
+
522
+ # 操作完了後の処理
523
+ @selected_items.clear
524
+ @directory_listing.refresh if @directory_listing
525
+
526
+ puts "\n#{operation == :move ? 'Moved' : 'Copied'} #{success_count} item(s)."
527
+ print 'Press any key to continue...'
528
+ STDIN.getch
529
+ true
530
+ end
531
+
532
+ def delete_selected_files
533
+ return false if @selected_items.empty?
534
+
535
+ if show_delete_confirmation(@selected_items.length)
536
+ perform_delete_operation(@selected_items)
537
+ else
538
+ false
539
+ end
540
+ end
541
+
542
+ def show_delete_confirmation(count)
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
598
+ end
599
+
600
+ def perform_delete_operation(items)
601
+ success_count = 0
602
+ error_messages = []
603
+ current_path = @directory_listing&.current_path || Dir.pwd
604
+ debug_log = []
605
+
606
+ items.each do |item_name|
607
+ item_path = File.join(current_path, item_name)
608
+ debug_log << "Processing: #{item_name}"
609
+
610
+ begin
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
623
+ FileUtils.rm_rf(item_path)
624
+ debug_log << ' FileUtils.rm_rf executed'
625
+ else
626
+ FileUtils.rm(item_path)
627
+ debug_log << ' FileUtils.rm executed'
628
+ end
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}"
645
+ end
646
+ end
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
+
678
+ # 削除完了後の処理
679
+ @selected_items.clear
680
+ @directory_listing.refresh if @directory_listing
681
+
682
+ true
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
438
875
  end
439
- end
876
+ end