rufio 0.80.0 → 0.82.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.
@@ -6,9 +6,9 @@ require_relative 'text_utils'
6
6
  module Rufio
7
7
  class TerminalUI
8
8
  # Layout constants
9
- HEADER_HEIGHT = 2 # Header占有行数(2段目のモードタブを含む)
9
+ HEADER_HEIGHT = 1 # Header占有行数(モードタブ+パス+バージョン 1行に統合)
10
10
  FOOTER_HEIGHT = 1 # Footer占有行数(ブックマーク一覧 + ステータス情報)
11
- HEADER_FOOTER_MARGIN = 3 # Header(2行) + Footer(1行)分のマージン
11
+ HEADER_FOOTER_MARGIN = 2 # Header(1行) + Footer(1行)分のマージン
12
12
 
13
13
  # Panel layout ratios
14
14
  LEFT_PANEL_RATIO = 0.5 # 左パネルの幅比率
@@ -19,6 +19,7 @@ module Rufio
19
19
  DEFAULT_SCREEN_HEIGHT = 24 # デフォルト画面高さ
20
20
  HEADER_PADDING = 2 # ヘッダーのパディング
21
21
  FILTER_TEXT_RESERVED = 15 # フィルタテキスト表示の予約幅
22
+ TAB_SEPARATOR = ">" # タブ間セパレータ
22
23
 
23
24
  # File display constants
24
25
  ICON_SIZE_PADDING = 12 # アイコン、選択マーク、サイズ情報分
@@ -29,8 +30,11 @@ module Rufio
29
30
  MEGABYTE = KILOBYTE * 1024
30
31
  GIGABYTE = MEGABYTE * 1024
31
32
 
33
+ # Bookmark highlight duration (seconds)
34
+ BOOKMARK_HIGHLIGHT_DURATION = 0.5
35
+
32
36
  # Line offsets
33
- CONTENT_START_LINE = 2 # コンテンツ開始行(ヘッダー2行: Y=0, Y=1
37
+ CONTENT_START_LINE = 1 # コンテンツ開始行(フッタ1行: Y=0)
34
38
 
35
39
  def initialize(test_mode: false)
36
40
  console = IO.console
@@ -71,19 +75,23 @@ module Rufio
71
75
  # 非同期ハイライト完了フラグ(Thread → メインループへの通知)
72
76
  @highlight_updated = false
73
77
 
74
- # Footer cache (bookmark list)
75
- @cached_bookmarks = nil
76
- @cached_bookmark_time = nil
77
- @bookmark_cache_ttl = 1.0 # 1秒間キャッシュ
78
-
79
- # Command execution lamp (footer indicator)
80
- @completion_lamp_message = nil
81
- @completion_lamp_time = nil
82
78
 
83
79
  # Tab mode manager
84
80
  @tab_mode_manager = TabModeManager.new
81
+
82
+ # UIRenderer(描画ロジックを担当)
83
+ ui_opts = ConfigLoader.ui_options
84
+ @ui_renderer = UIRenderer.new(
85
+ screen_width: @screen_width,
86
+ screen_height: @screen_height,
87
+ test_mode: @test_mode,
88
+ left_panel_ratio: ui_opts[:panel_ratio],
89
+ preview_enabled: ui_opts[:preview_enabled]
90
+ )
85
91
  end
86
92
 
93
+ attr_reader :ui_renderer
94
+
87
95
  def start(directory_listing, keybind_handler, file_preview, background_executor = nil)
88
96
  @directory_listing = directory_listing
89
97
  @keybind_handler = keybind_handler
@@ -92,6 +100,12 @@ module Rufio
92
100
  @keybind_handler.set_directory_listing(@directory_listing)
93
101
  @keybind_handler.set_terminal_ui(self)
94
102
 
103
+ # UIRenderer に依存を注入
104
+ @ui_renderer.keybind_handler = @keybind_handler
105
+ @ui_renderer.directory_listing = @directory_listing
106
+ @ui_renderer.file_preview = @file_preview
107
+ @ui_renderer.background_executor = @background_executor
108
+
95
109
  # command_mode_ui にも terminal_ui を設定
96
110
  @command_mode_ui.set_terminal_ui(self)
97
111
 
@@ -158,6 +172,8 @@ module Rufio
158
172
 
159
173
  private
160
174
 
175
+ # ブックマークハイライトが期限切れかどうか
176
+ # @return [Boolean] true=期限切れ or ハイライト中でない, false=ハイライト中
161
177
  def setup_terminal
162
178
  # terminal setup
163
179
  system('tput smcup') # alternate screen
@@ -214,7 +230,7 @@ module Rufio
214
230
  notification_message = nil
215
231
  notification_time = nil
216
232
  previous_notification = nil
217
- previous_lamp_message = @completion_lamp_message
233
+ previous_lamp_message = @ui_renderer.completion_lamp_message
218
234
 
219
235
  # FPS計測用
220
236
  frame_times = []
@@ -262,9 +278,9 @@ module Rufio
262
278
  # 通知メッセージとして表示
263
279
  notification_message = completion_msg
264
280
  notification_time = start
265
- # フッターのランプ表示用にも設定
266
- @completion_lamp_message = completion_msg
267
- @completion_lamp_time = start
281
+ # フッターのランプ表示用にも設定(UIRenderer が管理)
282
+ @ui_renderer.completion_lamp_message = completion_msg
283
+ @ui_renderer.completion_lamp_time = start
268
284
  @background_executor.instance_variable_set(:@completion_message, nil) # メッセージをクリア
269
285
  needs_redraw = true
270
286
  end
@@ -282,14 +298,15 @@ module Rufio
282
298
 
283
299
  # 完了ランプの表示状態をチェック(0.5秒ごと)
284
300
  if (start - last_lamp_check) > 0.5
285
- current_lamp = @completion_lamp_message
301
+ current_lamp = @ui_renderer.completion_lamp_message
286
302
  if current_lamp != previous_lamp_message
287
303
  previous_lamp_message = current_lamp
288
304
  needs_redraw = true
289
305
  end
290
306
  # 完了ランプのタイムアウトチェック
291
- if @completion_lamp_message && @completion_lamp_time && (start - @completion_lamp_time) >= 3.0
292
- @completion_lamp_message = nil
307
+ if @ui_renderer.completion_lamp_message && @ui_renderer.completion_lamp_time &&
308
+ (start - @ui_renderer.completion_lamp_time) >= 3.0
309
+ @ui_renderer.completion_lamp_message = nil
293
310
  needs_redraw = true
294
311
  end
295
312
  last_lamp_check = start
@@ -309,6 +326,12 @@ module Rufio
309
326
  needs_redraw = true
310
327
  end
311
328
 
329
+ # ブックマークハイライトのタイムアウトチェック(500ms 後に自動消去)
330
+ if @ui_renderer.bookmark_highlight_expired?
331
+ @ui_renderer.clear_highlighted_bookmark
332
+ needs_redraw = true
333
+ end
334
+
312
335
  # DRAW & RENDER phase - 変更があった場合のみ描画
313
336
  if needs_redraw
314
337
  # Screenバッファに描画(clearは呼ばない。必要な部分だけ更新)
@@ -345,941 +368,19 @@ module Rufio
345
368
  sleep sleep_time if sleep_time > 0
346
369
  end
347
370
  end
371
+ public
348
372
 
349
- def draw_screen
350
- # 処理時間測定開始
351
- start_time = Time.now
352
-
353
- # move cursor to top of screen (don't clear)
354
- print "\e[H"
355
-
356
- # ジョブモードの場合は専用の画面を描画
357
- if @in_job_mode
358
- draw_job_mode_screen
359
- return
360
- end
361
-
362
- # header (1 line)
363
- draw_header
364
-
365
- # main content (left: directory list, right: preview)
366
- entries = get_display_entries
367
- selected_entry = entries[@keybind_handler.current_index]
368
-
369
- # calculate height with header and footer margin
370
- content_height = @screen_height - HEADER_FOOTER_MARGIN
371
- left_width = (@screen_width * LEFT_PANEL_RATIO).to_i
372
- right_width = @screen_width - left_width
373
-
374
- # adjust so right panel doesn't overflow into left panel
375
- right_width = @screen_width - left_width if left_width + right_width > @screen_width
376
-
377
- draw_directory_list(entries, left_width, content_height)
378
- draw_file_preview(selected_entry, right_width, content_height, left_width)
379
-
380
- # footer (統合されたステータス情報を含む)
381
- render_time = Time.now - start_time
382
- draw_footer(render_time)
383
-
384
- # コマンドモードがアクティブな場合はコマンド入力ウィンドウを表示
385
- if @command_mode_active
386
- # フローティングウィンドウで表示
387
- @command_mode_ui.show_input_prompt(@command_input)
388
- else
389
- # move cursor to invisible position
390
- print "\e[#{@screen_height};#{@screen_width}H"
391
- end
392
-
393
- # 通知を描画(右上にオーバーレイ)
394
- draw_notifications
395
- end
396
-
397
- # Phase 3: Screenバッファに描画する新しいメソッド
373
+ # UIRenderer に全描画処理を委譲
398
374
  def draw_screen_to_buffer(screen, notification_message = nil, fps = nil)
399
- # header (2 lines) - y=0, y=1(全モード共通)
400
- draw_header_to_buffer(screen, 0)
401
- draw_mode_tabs_to_buffer(screen, 1)
402
-
403
- # calculate height with header and footer margin
404
- content_height = @screen_height - HEADER_FOOTER_MARGIN
405
-
406
- if @in_job_mode
407
- # ジョブモード: ジョブ一覧を表示
408
- draw_job_list_to_buffer(screen, content_height)
409
- draw_job_footer_to_buffer(screen, @screen_height - 1)
410
- else
411
- # 通常モード: ファイル一覧とプレビューを表示
412
- entries = get_display_entries
413
- selected_entry = entries[@keybind_handler.current_index]
414
-
415
- left_width = (@screen_width * LEFT_PANEL_RATIO).to_i
416
- right_width = @screen_width - left_width
417
-
418
- # adjust so right panel doesn't overflow into left panel
419
- right_width = @screen_width - left_width if left_width + right_width > @screen_width
420
-
421
- draw_directory_list_to_buffer(screen, entries, left_width, content_height)
422
- draw_file_preview_to_buffer(screen, selected_entry, right_width, content_height, left_width)
423
-
424
- # footer
425
- draw_footer_to_buffer(screen, @screen_height - 1, fps)
426
- end
427
-
428
- # 通知メッセージがある場合は表示
429
- if notification_message
430
- notification_line = @screen_height - 1
431
- message_display = " #{notification_message} "
432
- if message_display.length > @screen_width
433
- message_display = message_display[0...(@screen_width - 3)] + "..."
434
- end
435
- screen.put_string(0, notification_line, message_display.ljust(@screen_width), fg: "\e[7m")
436
- end
437
- end
438
-
439
- # ジョブ一覧をバッファに描画
440
- def draw_job_list_to_buffer(screen, height)
441
- return unless @job_manager
442
-
443
- jobs = @job_manager.jobs
444
- selected_index = @job_mode_instance&.selected_index || 0
445
-
446
- (0...height).each do |i|
447
- line_num = i + CONTENT_START_LINE
448
-
449
- if i < jobs.length
450
- job = jobs[i]
451
- draw_job_line_to_buffer(screen, job, i == selected_index, line_num)
452
- else
453
- # 空行
454
- screen.put_string(0, line_num, ' ' * @screen_width)
455
- end
456
- end
457
- end
458
-
459
- # ジョブ行をバッファに描画
460
- def draw_job_line_to_buffer(screen, job, is_selected, y)
461
- icon = job.status_icon
462
- name = job.name
463
- path = "(#{job.path})"
464
- duration = job.formatted_duration
465
- duration_text = duration.empty? ? "" : "[#{duration}]"
466
-
467
- status_text = case job.status
468
- when :running then "Running"
469
- when :completed then "Done"
470
- when :failed then "Failed"
471
- when :waiting then "Waiting"
472
- when :cancelled then "Cancelled"
473
- else ""
474
- end
475
-
476
- # ステータスに応じた色
477
- status_color = case job.status
478
- when :running then "\e[33m" # Yellow
479
- when :completed then "\e[32m" # Green
480
- when :failed then "\e[31m" # Red
481
- else "\e[37m" # White
482
- end
483
-
484
- # 行を構築
485
- line_content = "#{icon} #{name} #{path}".ljust(40)
486
- line_content += "#{duration_text.ljust(12)} #{status_text}"
487
- line_content = line_content[0...@screen_width].ljust(@screen_width)
488
-
489
- if is_selected
490
- # 選択中: 反転表示
491
- line_content.each_char.with_index do |char, x|
492
- screen.put(x, y, char, fg: "\e[30m", bg: "\e[47m")
493
- end
494
- else
495
- # 非選択: ステータス色
496
- line_content.each_char.with_index do |char, x|
497
- screen.put(x, y, char, fg: status_color)
498
- end
499
- end
500
- end
501
-
502
- # ジョブモード用フッターをバッファに描画
503
- def draw_job_footer_to_buffer(screen, y)
504
- job_count = @job_manager&.job_count || 0
505
- help_text = "[Space] View Log | [x] Cancel | [Tab] Switch Mode | Jobs: #{job_count}"
506
- footer_content = help_text.center(@screen_width)[0...@screen_width]
507
-
508
- footer_content.each_char.with_index do |char, x|
509
- screen.put(x, y, char, fg: "\e[30m", bg: "\e[47m")
510
- end
511
- end
512
-
513
- def draw_screen_with_notification(notification_message)
514
- # 通常の画面を描画
515
- draw_screen
516
-
517
- # 通知メッセージを画面下部に表示
518
- notification_line = @screen_height - 1
519
- print "\e[#{notification_line};1H" # カーソルを画面下部に移動
520
-
521
- # 通知メッセージを反転表示で目立たせる
522
- message_display = " #{notification_message} "
523
- if message_display.length > @screen_width
524
- message_display = message_display[0...(@screen_width - 3)] + "..."
525
- end
526
-
527
- print "\e[7m#{message_display.ljust(@screen_width)}\e[0m"
528
- end
529
-
530
- # Phase 3: Screenバッファにヘッダーを描画
531
- def draw_header_to_buffer(screen, y)
532
- current_path = @directory_listing.current_path
533
- header = "💎 rufio v#{VERSION} - #{current_path}"
534
-
535
- # Add help mode indicator if in help mode
536
- if @keybind_handler.help_mode?
537
- header += " [Help Mode - Press ESC to exit]"
538
- end
539
-
540
- # Add filter indicator if in filter mode
541
- if @keybind_handler.filter_active?
542
- filter_text = " [Filter: #{@keybind_handler.filter_query}]"
543
- header += filter_text
544
- end
545
-
546
- # abbreviate if path is too long
547
- if header.length > @screen_width - HEADER_PADDING
548
- if @keybind_handler.help_mode?
549
- # prioritize showing help mode indicator
550
- help_text = " [Help Mode - Press ESC to exit]"
551
- base_length = @screen_width - help_text.length - FILTER_TEXT_RESERVED
552
- header = "💎 rufio v#{VERSION} - ...#{current_path[-base_length..-1]}#{help_text}"
553
- elsif @keybind_handler.filter_active?
554
- # prioritize showing filter when active
555
- filter_text = " [Filter: #{@keybind_handler.filter_query}]"
556
- base_length = @screen_width - filter_text.length - FILTER_TEXT_RESERVED
557
- header = "💎 rufio v#{VERSION} - ...#{current_path[-base_length..-1]}#{filter_text}"
558
- else
559
- header = "💎 rufio v#{VERSION} - ...#{current_path[-(@screen_width - FILTER_TEXT_RESERVED)..-1]}"
560
- end
561
- end
562
-
563
- screen.put_string(0, y, header.ljust(@screen_width), fg: "\e[7m")
564
- end
565
-
566
- # Phase 3: Screenバッファにモードタブを描画
567
- def draw_mode_tabs_to_buffer(screen, y)
568
- # タブモードマネージャの状態を同期
569
- sync_tab_mode_with_keybind_handler
570
-
571
- current_x = 0
572
- modes = @tab_mode_manager.available_modes
573
- labels = @tab_mode_manager.mode_labels
574
- current_mode = @tab_mode_manager.current_mode
575
-
576
- modes.each_with_index do |mode, index|
577
- label = " #{labels[mode]} "
578
-
579
- if mode == current_mode
580
- # 現在のモード: シアン背景 + 黒文字 + 太字
581
- label.each_char do |char|
582
- screen.put(current_x, y, char, fg: "\e[30m\e[1m", bg: "\e[46m")
583
- current_x += 1
584
- end
585
- else
586
- # 非選択モード: グレー文字
587
- label.each_char do |char|
588
- screen.put(current_x, y, char, fg: "\e[90m")
589
- current_x += 1
590
- end
591
- end
592
-
593
- # 区切り線(最後のモード以外)
594
- if index < modes.length - 1
595
- screen.put(current_x, y, '│', fg: "\e[90m")
596
- current_x += 1
597
- end
598
- end
599
-
600
- # 残りをスペースで埋める
601
- while current_x < @screen_width
602
- screen.put(current_x, y, ' ')
603
- current_x += 1
604
- end
605
- end
606
-
607
- # キーバインドハンドラの状態とタブモードを同期
608
- def sync_tab_mode_with_keybind_handler
609
- return unless @keybind_handler
610
-
611
- current_mode = if @keybind_handler.help_mode?
612
- :help
613
- elsif @keybind_handler.log_viewer_mode?
614
- :logs
615
- elsif @keybind_handler.in_job_mode?
616
- :jobs
617
- else
618
- :files
619
- end
620
-
621
- @tab_mode_manager.switch_to(current_mode) if @tab_mode_manager.current_mode != current_mode
622
- end
623
-
624
- def draw_header
625
- current_path = @directory_listing.current_path
626
- header = "💎 rufio v#{VERSION} - #{current_path}"
627
-
628
- # Add help mode indicator if in help mode
629
- if @keybind_handler.help_mode?
630
- header += " [Help Mode - Press ESC to exit]"
631
- end
632
-
633
- # Add filter indicator if in filter mode
634
- if @keybind_handler.filter_active?
635
- filter_text = " [Filter: #{@keybind_handler.filter_query}]"
636
- header += filter_text
637
- end
638
-
639
- # abbreviate if path is too long
640
- if header.length > @screen_width - HEADER_PADDING
641
- if @keybind_handler.help_mode?
642
- # prioritize showing help mode indicator
643
- help_text = " [Help Mode - Press ESC to exit]"
644
- base_length = @screen_width - help_text.length - FILTER_TEXT_RESERVED
645
- header = "💎 rufio v#{VERSION} - ...#{current_path[-base_length..-1]}#{help_text}"
646
- elsif @keybind_handler.filter_active?
647
- # prioritize showing filter when active
648
- filter_text = " [Filter: #{@keybind_handler.filter_query}]"
649
- base_length = @screen_width - filter_text.length - FILTER_TEXT_RESERVED
650
- header = "💎 rufio v#{VERSION} - ...#{current_path[-base_length..-1]}#{filter_text}"
651
- else
652
- header = "💎 rufio v#{VERSION} - ...#{current_path[-(@screen_width - FILTER_TEXT_RESERVED)..-1]}"
653
- end
654
- end
655
-
656
- puts "\e[7m#{header.ljust(@screen_width)}\e[0m" # reverse display
657
- end
658
-
659
-
660
-
661
- # Phase 3: Screenバッファにディレクトリリストを描画
662
- def draw_directory_list_to_buffer(screen, entries, width, height)
663
- start_index = [@keybind_handler.current_index - height / 2, 0].max
664
-
665
- (0...height).each do |i|
666
- entry_index = start_index + i
667
- line_num = i + CONTENT_START_LINE
668
-
669
- if entry_index < entries.length
670
- entry = entries[entry_index]
671
- is_selected = entry_index == @keybind_handler.current_index
672
-
673
- draw_entry_line_to_buffer(screen, entry, width, is_selected, 0, line_num)
674
- else
675
- # 空行
676
- safe_width = [width - CURSOR_OFFSET, (@screen_width * LEFT_PANEL_RATIO).to_i - CURSOR_OFFSET].min
677
- screen.put_string(0, line_num, ' ' * safe_width)
678
- end
679
- end
680
- end
681
-
682
- def draw_directory_list(entries, width, height)
683
- start_index = [@keybind_handler.current_index - height / 2, 0].max
684
- [start_index + height - 1, entries.length - 1].min
685
-
686
- (0...height).each do |i|
687
- entry_index = start_index + i
688
- line_num = i + CONTENT_START_LINE
689
-
690
- print "\e[#{line_num};1H" # set cursor position
691
-
692
- if entry_index < entries.length
693
- entry = entries[entry_index]
694
- is_selected = entry_index == @keybind_handler.current_index
695
-
696
- draw_entry_line(entry, width, is_selected)
697
- else
698
- # 左ペイン専用の安全な幅で空行を出力
699
- safe_width = [width - CURSOR_OFFSET, (@screen_width * LEFT_PANEL_RATIO).to_i - CURSOR_OFFSET].min
700
- print ' ' * safe_width
701
- end
702
- end
703
- end
704
-
705
- # Phase 3: Screenバッファにエントリ行を描画
706
- def draw_entry_line_to_buffer(screen, entry, width, is_selected, x, y)
707
- # アイコンと色の設定
708
- icon, color = get_entry_display_info(entry)
709
-
710
- # 左ペイン専用の安全な幅を計算
711
- safe_width = [width - CURSOR_OFFSET, (@screen_width * LEFT_PANEL_RATIO).to_i - CURSOR_OFFSET].min
712
-
713
- # 選択マークの追加
714
- selection_mark = @keybind_handler.is_selected?(entry[:name]) ? "✓ " : " "
715
-
716
- # ファイル名(必要に応じて切り詰め)
717
- name = entry[:name]
718
- max_name_length = safe_width - ICON_SIZE_PADDING
719
- name = name[0...max_name_length - 3] + '...' if max_name_length > 0 && name.length > max_name_length
720
-
721
- # サイズ情報
722
- size_info = format_size(entry[:size])
723
-
724
- # 行の内容を構築
725
- content_without_size = "#{selection_mark}#{icon} #{name}"
726
- available_for_content = safe_width - size_info.length
727
-
728
- line_content = if available_for_content > 0
729
- content_without_size.ljust(available_for_content) + size_info
730
- else
731
- content_without_size
732
- end
733
-
734
- # 確実に safe_width を超えないよう切り詰め
735
- line_content = line_content[0...safe_width]
736
-
737
- # 色を決定
738
- if is_selected
739
- fg_color = ColorHelper.color_to_selected_ansi(ConfigLoader.colors[:selected])
740
- screen.put_string(x, y, line_content, fg: fg_color)
741
- elsif @keybind_handler.is_selected?(entry[:name])
742
- # 選択されたアイテムは緑背景、黒文字
743
- screen.put_string(x, y, line_content, fg: "\e[42m\e[30m")
744
- else
745
- screen.put_string(x, y, line_content, fg: color)
746
- end
747
- end
748
-
749
- def draw_entry_line(entry, width, is_selected)
750
- # アイコンと色の設定
751
- icon, color = get_entry_display_info(entry)
752
-
753
- # 左ペイン専用の安全な幅を計算(右ペインにはみ出さないよう)
754
- safe_width = [width - CURSOR_OFFSET, (@screen_width * LEFT_PANEL_RATIO).to_i - CURSOR_OFFSET].min
755
-
756
- # 選択マークの追加
757
- selection_mark = @keybind_handler.is_selected?(entry[:name]) ? "✓ " : " "
758
-
759
- # ファイル名(必要に応じて切り詰め)
760
- name = entry[:name]
761
- max_name_length = safe_width - ICON_SIZE_PADDING
762
- name = name[0...max_name_length - 3] + '...' if max_name_length > 0 && name.length > max_name_length
763
-
764
- # サイズ情報
765
- size_info = format_size(entry[:size])
766
-
767
- # 行の内容を構築(安全な幅内で)
768
- content_without_size = "#{selection_mark}#{icon} #{name}"
769
- available_for_content = safe_width - size_info.length
770
-
771
- line_content = if available_for_content > 0
772
- content_without_size.ljust(available_for_content) + size_info
773
- else
774
- content_without_size
775
- end
776
-
777
- # 確実に safe_width を超えないよう切り詰め
778
- line_content = line_content[0...safe_width]
779
-
780
- if is_selected
781
- selected_color = ColorHelper.color_to_selected_ansi(ConfigLoader.colors[:selected])
782
- print "#{selected_color}#{line_content}#{ColorHelper.reset}"
783
- else
784
- # 選択されたアイテムは異なる色で表示
785
- if @keybind_handler.is_selected?(entry[:name])
786
- print "\e[42m\e[30m#{line_content}\e[0m" # 緑背景、黒文字
787
- else
788
- print "#{color}#{line_content}#{ColorHelper.reset}"
789
- end
790
- end
791
- end
792
-
793
- def get_entry_display_info(entry)
794
- colors = ConfigLoader.colors
795
-
796
- case entry[:type]
797
- when 'directory'
798
- color_code = ColorHelper.color_to_ansi(colors[:directory])
799
- ['📁', color_code]
800
- when 'executable'
801
- color_code = ColorHelper.color_to_ansi(colors[:executable])
802
- ['⚡', color_code]
803
- else
804
- case File.extname(entry[:name]).downcase
805
- when '.rb'
806
- ['💎', "\e[31m"] # 赤
807
- when '.js', '.ts'
808
- ['📜', "\e[33m"] # 黄
809
- when '.txt', '.md'
810
- color_code = ColorHelper.color_to_ansi(colors[:file])
811
- ['📄', color_code]
812
- else
813
- color_code = ColorHelper.color_to_ansi(colors[:file])
814
- ['📄', color_code]
815
- end
816
- end
817
- end
818
-
819
- def format_size(size)
820
- return ' ' if size == 0
821
-
822
- if size < KILOBYTE
823
- "#{size}B".rjust(6)
824
- elsif size < MEGABYTE
825
- "#{(size / KILOBYTE.to_f).round(1)}K".rjust(6)
826
- elsif size < GIGABYTE
827
- "#{(size / MEGABYTE.to_f).round(1)}M".rjust(6)
828
- else
829
- "#{(size / GIGABYTE.to_f).round(1)}G".rjust(6)
830
- end
831
- end
832
-
833
- # Phase 3: Screenバッファにファイルプレビューを描画
834
- def draw_file_preview_to_buffer(screen, selected_entry, width, height, left_offset)
835
- # 事前計算
836
- cursor_position = left_offset + CURSOR_OFFSET
837
- max_chars_from_cursor = @screen_width - cursor_position
838
- safe_width = [max_chars_from_cursor - 2, width - 2, 0].max
839
-
840
- # プレビューコンテンツをキャッシュから取得(毎フレームのファイルI/Oを回避)
841
- preview_content = nil
842
- wrapped_lines = nil
843
- highlighted_wrapped_lines = nil
844
-
845
- if selected_entry && selected_entry[:type] == 'file'
846
- # キャッシュチェック: 選択ファイルが変わった場合のみプレビューを更新
847
- if @last_preview_path != selected_entry[:path]
848
- full_preview = @file_preview.preview_file(selected_entry[:path])
849
- preview_content = extract_preview_lines(full_preview)
850
- @preview_cache[selected_entry[:path]] = {
851
- content: preview_content,
852
- preview_data: full_preview,
853
- highlighted: nil, # nil = 未取得
854
- wrapped: {},
855
- highlighted_wrapped: {}
856
- }
857
- @last_preview_path = selected_entry[:path]
858
- else
859
- # キャッシュから取得
860
- cache_entry = @preview_cache[selected_entry[:path]]
861
- preview_content = cache_entry[:content] if cache_entry
862
- end
863
-
864
- # bat が利用可能な場合はシンタックスハイライトを取得(非同期)
865
- if @syntax_highlighter&.available? && preview_content
866
- cache_entry = @preview_cache[selected_entry[:path]]
867
- if cache_entry
868
- preview_data = cache_entry[:preview_data]
869
- if preview_data && preview_data[:type] == 'code' && preview_data[:encoding] == 'UTF-8'
870
- # ハイライト行を未取得なら非同期で bat を呼び出す
871
- # nil = 未リクエスト、false = リクエスト済み(結果待ち)、Array = 取得済み
872
- if cache_entry[:highlighted].nil?
873
- # 即座に false をセットしてペンディング状態にする(重複リクエスト防止)
874
- cache_entry[:highlighted] = false
875
- file_path = selected_entry[:path]
876
- @syntax_highlighter.highlight_async(file_path) do |lines|
877
- # バックグラウンドスレッドからキャッシュを更新
878
- if (ce = @preview_cache[file_path])
879
- ce[:highlighted] = lines
880
- ce[:highlighted_wrapped] = {} # 折り返しキャッシュをクリア
881
- end
882
- @highlight_updated = true # メインループに再描画を通知
883
- end
884
- # このフレームはプレーンテキストで表示(次フレームでハイライト表示)
885
- end
886
-
887
- highlighted = cache_entry[:highlighted]
888
- if highlighted.is_a?(Array) && !highlighted.empty? && safe_width > 0
889
- if cache_entry[:highlighted_wrapped][safe_width]
890
- highlighted_wrapped_lines = cache_entry[:highlighted_wrapped][safe_width]
891
- else
892
- # 各ハイライト行をトークン化して折り返す
893
- hl_wrapped = highlighted.flat_map do |hl_line|
894
- tokens = AnsiLineParser.parse(hl_line)
895
- tokens.empty? ? [[]] : AnsiLineParser.wrap(tokens, safe_width - 1)
896
- end
897
- cache_entry[:highlighted_wrapped][safe_width] = hl_wrapped
898
- highlighted_wrapped_lines = hl_wrapped
899
- end
900
- end
901
- end
902
- end
903
- end
904
-
905
- # プレーンテキストの折り返し(ハイライトなしのフォールバック)
906
- if preview_content && safe_width > 0 && highlighted_wrapped_lines.nil?
907
- cache_entry = @preview_cache[selected_entry[:path]]
908
- if cache_entry && cache_entry[:wrapped][safe_width]
909
- wrapped_lines = cache_entry[:wrapped][safe_width]
910
- else
911
- wrapped_lines = TextUtils.wrap_preview_lines(preview_content, safe_width - 1)
912
- cache_entry[:wrapped][safe_width] = wrapped_lines if cache_entry
913
- end
914
- end
915
- end
916
-
917
- content_x = cursor_position + 1
918
-
919
- (0...height).each do |i|
920
- line_num = i + CONTENT_START_LINE
921
-
922
- # 区切り線
923
- screen.put(cursor_position, line_num, '│')
924
-
925
- next if safe_width <= 0
926
-
927
- if selected_entry && i == 0
928
- # プレビューヘッダー
929
- header = " #{selected_entry[:name]} "
930
- header += "[PREVIEW MODE]" if @keybind_handler&.preview_focused?
931
- header = TextUtils.truncate_to_width(header, safe_width) if TextUtils.display_width(header) > safe_width
932
- remaining_space = safe_width - TextUtils.display_width(header)
933
- header += ' ' * remaining_space if remaining_space > 0
934
- screen.put_string(content_x, line_num, header)
935
-
936
- elsif i >= 2 && highlighted_wrapped_lines
937
- # シンタックスハイライト付きコンテンツ
938
- scroll_offset = @keybind_handler&.preview_scroll_offset || 0
939
- display_line_index = i - 2 + scroll_offset
940
-
941
- if display_line_index < highlighted_wrapped_lines.length
942
- draw_highlighted_line_to_buffer(screen, content_x, line_num,
943
- highlighted_wrapped_lines[display_line_index], safe_width)
944
- else
945
- screen.put_string(content_x, line_num, ' ' * safe_width)
946
- end
947
-
948
- elsif i >= 2 && wrapped_lines
949
- # プレーンテキストコンテンツ
950
- scroll_offset = @keybind_handler&.preview_scroll_offset || 0
951
- display_line_index = i - 2 + scroll_offset
952
-
953
- content_to_print = if display_line_index < wrapped_lines.length
954
- " #{wrapped_lines[display_line_index] || ''}"
955
- else
956
- ' '
957
- end
958
- content_to_print = TextUtils.truncate_to_width(content_to_print, safe_width) if TextUtils.display_width(content_to_print) > safe_width
959
- remaining_space = safe_width - TextUtils.display_width(content_to_print)
960
- content_to_print += ' ' * remaining_space if remaining_space > 0
961
- screen.put_string(content_x, line_num, content_to_print)
962
-
963
- else
964
- screen.put_string(content_x, line_num, ' ' * safe_width)
965
- end
966
- end
967
- end
968
-
969
- def draw_file_preview(selected_entry, width, height, left_offset)
970
- # 事前計算(ループの外で一度だけ)
971
- cursor_position = left_offset + CURSOR_OFFSET
972
- max_chars_from_cursor = @screen_width - cursor_position
973
- safe_width = [max_chars_from_cursor - 2, width - 2, 0].max
974
-
975
- # プレビューコンテンツをキャッシュから取得(毎フレームのファイルI/Oを回避)
976
- preview_content = nil
977
- wrapped_lines = nil
978
-
979
- if selected_entry && selected_entry[:type] == 'file'
980
- # キャッシュチェック: 選択ファイルが変わった場合のみプレビューを更新
981
- if @last_preview_path != selected_entry[:path]
982
- preview_content = get_preview_content(selected_entry)
983
- @preview_cache[selected_entry[:path]] = {
984
- content: preview_content,
985
- wrapped: {} # 幅ごとにキャッシュ
986
- }
987
- @last_preview_path = selected_entry[:path]
988
- else
989
- # キャッシュから取得
990
- cache_entry = @preview_cache[selected_entry[:path]]
991
- preview_content = cache_entry[:content] if cache_entry
992
- end
993
-
994
- # 折り返し処理もキャッシュ
995
- if preview_content && safe_width > 0
996
- cache_entry = @preview_cache[selected_entry[:path]]
997
- if cache_entry && cache_entry[:wrapped][safe_width]
998
- wrapped_lines = cache_entry[:wrapped][safe_width]
999
- else
1000
- wrapped_lines = TextUtils.wrap_preview_lines(preview_content, safe_width - 1)
1001
- cache_entry[:wrapped][safe_width] = wrapped_lines if cache_entry
1002
- end
1003
- end
1004
- end
1005
-
1006
- (0...height).each do |i|
1007
- line_num = i + CONTENT_START_LINE
1008
-
1009
- print "\e[#{line_num};#{cursor_position}H" # カーソル位置設定
1010
- print '│' # 区切り線
1011
-
1012
- content_to_print = ''
1013
-
1014
- if selected_entry && i == 0
1015
- # プレビューヘッダー
1016
- header = " #{selected_entry[:name]} "
1017
- # プレビューフォーカス中は表示を追加
1018
- if @keybind_handler&.preview_focused?
1019
- header += "[PREVIEW MODE]"
1020
- end
1021
- content_to_print = header
1022
- elsif wrapped_lines && i >= 2
1023
- # ファイルプレビュー(折り返し対応)
1024
- # スクロールオフセットを適用
1025
- scroll_offset = @keybind_handler&.preview_scroll_offset || 0
1026
- display_line_index = i - 2 + scroll_offset
1027
-
1028
- if display_line_index < wrapped_lines.length
1029
- line = wrapped_lines[display_line_index] || ''
1030
- # スペースを先頭に追加
1031
- content_to_print = " #{line}"
1032
- else
1033
- content_to_print = ' '
1034
- end
1035
- else
1036
- content_to_print = ' '
1037
- end
1038
-
1039
- # 絶対にsafe_widthを超えないよう強制的に切り詰める
1040
- if safe_width <= 0
1041
- # 表示スペースがない場合は何も出力しない
1042
- next
1043
- elsif TextUtils.display_width(content_to_print) > safe_width
1044
- # 表示幅ベースで切り詰める
1045
- content_to_print = TextUtils.truncate_to_width(content_to_print, safe_width)
1046
- end
1047
-
1048
- # 出力(パディングなし、はみ出し防止のため)
1049
- print content_to_print
1050
-
1051
- # 残りのスペースを埋める(ただし安全な範囲内のみ)
1052
- remaining_space = safe_width - TextUtils.display_width(content_to_print)
1053
- print ' ' * remaining_space if remaining_space > 0
1054
- end
1055
- end
1056
-
1057
- def get_preview_content(entry)
1058
- return [] unless entry && entry[:type] == 'file'
1059
-
1060
- preview = @file_preview.preview_file(entry[:path])
1061
- extract_preview_lines(preview)
1062
- rescue StandardError
1063
- ["(#{ConfigLoader.message('file.preview_error')})"]
1064
- end
1065
-
1066
- # FilePreview の結果ハッシュからプレーンテキスト行を抽出する
1067
- def extract_preview_lines(preview)
1068
- case preview[:type]
1069
- when 'text', 'code'
1070
- preview[:lines]
1071
- when 'binary'
1072
- ["(#{ConfigLoader.message('file.binary_file')})", ConfigLoader.message('file.cannot_preview')]
1073
- when 'error'
1074
- ["#{ConfigLoader.message('file.error_prefix')}:", preview[:message]]
1075
- else
1076
- ["(#{ConfigLoader.message('file.cannot_preview')})"]
1077
- end
1078
- rescue StandardError
1079
- ["(#{ConfigLoader.message('file.preview_error')})"]
1080
- end
1081
-
1082
- # ハイライト済みトークン列を1行分 Screen バッファに描画する
1083
- # 先頭に1スペースを追加し、残りをスペースで埋める
1084
- def draw_highlighted_line_to_buffer(screen, x, y, tokens, max_width)
1085
- current_x = x
1086
- max_x = x + max_width
1087
-
1088
- # 先頭スペース
1089
- if current_x < max_x
1090
- screen.put(current_x, y, ' ')
1091
- current_x += 1
1092
- end
1093
-
1094
- # トークンを描画
1095
- tokens&.each do |token|
1096
- break if current_x >= max_x
1097
- token[:text].each_char do |char|
1098
- char_w = TextUtils.char_width(char)
1099
- break if current_x + char_w > max_x
1100
- screen.put(current_x, y, char, fg: token[:fg])
1101
- current_x += char_w
1102
- end
1103
- end
1104
-
1105
- # 残りをスペースで埋める
1106
- while current_x < max_x
1107
- screen.put(current_x, y, ' ')
1108
- current_x += 1
1109
- end
1110
- end
1111
-
1112
-
1113
- def get_display_entries
1114
- entries = if @keybind_handler.filter_active?
1115
- # Get filtered entries from keybind_handler
1116
- all_entries = @directory_listing.list_entries
1117
- query = @keybind_handler.filter_query.downcase
1118
- query.empty? ? all_entries : all_entries.select { |entry| entry[:name].downcase.include?(query) }
1119
- else
1120
- @directory_listing.list_entries
1121
- end
1122
-
1123
- # ヘルプモードとLogsモードでは..を非表示にする
1124
- if @keybind_handler.help_mode? || @keybind_handler.log_viewer_mode?
1125
- entries.reject { |entry| entry[:name] == '..' }
1126
- else
1127
- entries
1128
- end
1129
- end
1130
-
1131
- # Phase 3: Screenバッファにフッターを描画
1132
- def draw_footer_to_buffer(screen, y, fps = nil)
1133
- if @keybind_handler.filter_active?
1134
- if @keybind_handler.instance_variable_get(:@filter_mode)
1135
- help_text = "Filter mode: Type to filter, ESC to clear, Enter to apply, Backspace to delete"
1136
- else
1137
- help_text = "Filtered view active - Space to edit filter, ESC to clear filter"
1138
- end
1139
- # フィルタモードでは通常のフッタを表示
1140
- footer_content = help_text.ljust(@screen_width)[0...@screen_width]
1141
- screen.put_string(0, y, footer_content, fg: "\e[7m")
1142
- else
1143
- # 通常モードではブックマーク一覧、ステータス情報、?:helpを1行に表示
1144
- # ブックマークをキャッシュ(毎フレームのファイルI/Oを回避)
1145
- current_time = Time.now
1146
- if @cached_bookmarks.nil? || @cached_bookmark_time.nil? || (current_time - @cached_bookmark_time) > @bookmark_cache_ttl
1147
- require_relative 'bookmark'
1148
- bookmark = Bookmark.new
1149
- @cached_bookmarks = bookmark.list
1150
- @cached_bookmark_time = current_time
1151
- end
1152
- bookmarks = @cached_bookmarks
1153
-
1154
- # 起動ディレクトリを取得
1155
- start_dir = @directory_listing&.start_directory
1156
- start_dir_name = if start_dir
1157
- File.basename(start_dir)
1158
- else
1159
- "start"
1160
- end
1161
-
1162
- # ブックマーク一覧を作成(0.起動dir を先頭に追加)
1163
- bookmark_parts = ["0.#{start_dir_name}"]
1164
- unless bookmarks.empty?
1165
- bookmark_parts.concat(bookmarks.take(9).map.with_index(1) { |bm, idx| "#{idx}.#{bm[:name]}" })
1166
- end
1167
- bookmark_text = bookmark_parts.join(" ")
1168
-
1169
- # 右側の情報: ジョブ数 | コマンド実行ランプ | FPS(test modeの時のみ)| ?:help
1170
- right_parts = []
1171
-
1172
- # ジョブ数を表示(ジョブがある場合のみ)
1173
- if @keybind_handler.has_jobs?
1174
- job_text = @keybind_handler.job_status_bar_text
1175
- right_parts << "[#{job_text}]" if job_text
1176
- end
1177
-
1178
- # バックグラウンドコマンドの実行状態をランプで表示
1179
- if @background_executor
1180
- if @background_executor.running?
1181
- # 実行中ランプ(緑色の回転矢印)
1182
- command_name = @background_executor.current_command || "処理中"
1183
- right_parts << "\e[32m🔄\e[0m #{command_name}"
1184
- elsif @completion_lamp_message && @completion_lamp_time
1185
- # 完了ランプ(3秒間表示)
1186
- if (Time.now - @completion_lamp_time) < 3.0
1187
- right_parts << @completion_lamp_message
1188
- else
1189
- @completion_lamp_message = nil
1190
- @completion_lamp_time = nil
1191
- end
1192
- end
1193
- end
1194
-
1195
- # FPS表示(test modeの時のみ)
1196
- if @test_mode && fps
1197
- right_parts << "#{fps.round(1)} FPS"
1198
- end
1199
-
1200
- # ヘルプ表示
1201
- right_parts << "?:help"
1202
-
1203
- right_info = right_parts.join(" | ")
1204
-
1205
- # ブックマーク一覧を利用可能な幅に収める
1206
- available_width = @screen_width - right_info.length - 3
1207
- if bookmark_text.length > available_width && available_width > 3
1208
- bookmark_text = bookmark_text[0...available_width - 3] + "..."
1209
- elsif available_width <= 3
1210
- bookmark_text = ""
1211
- end
1212
-
1213
- # フッタ全体を構築
1214
- padding = @screen_width - bookmark_text.length - right_info.length
1215
- footer_content = "#{bookmark_text}#{' ' * padding}#{right_info}"
1216
- footer_content = footer_content.ljust(@screen_width)[0...@screen_width]
1217
- screen.put_string(0, y, footer_content, fg: "\e[7m")
1218
- end
375
+ @ui_renderer.draw_screen_to_buffer(
376
+ screen, notification_message, fps,
377
+ in_job_mode: @in_job_mode,
378
+ job_manager: @job_manager,
379
+ job_mode_instance: @job_mode_instance
380
+ )
1219
381
  end
1220
382
 
1221
- def draw_footer(render_time = nil)
1222
- # フッタは最下行に表示
1223
- footer_line = @screen_height - FOOTER_HEIGHT + 1
1224
- print "\e[#{footer_line};1H"
1225
-
1226
- if @keybind_handler.filter_active?
1227
- if @keybind_handler.instance_variable_get(:@filter_mode)
1228
- help_text = "Filter mode: Type to filter, ESC to clear, Enter to apply, Backspace to delete"
1229
- else
1230
- help_text = "Filtered view active - Space to edit filter, ESC to clear filter"
1231
- end
1232
- # フィルタモードでは通常のフッタを表示
1233
- footer_content = help_text.ljust(@screen_width)[0...@screen_width]
1234
- print "\e[7m#{footer_content}\e[0m"
1235
- else
1236
- # 通常モードではブックマーク一覧、ステータス情報、?:helpを1行に表示
1237
- # ブックマークをキャッシュ(毎フレームのファイルI/Oを回避)
1238
- current_time = Time.now
1239
- if @cached_bookmarks.nil? || @cached_bookmark_time.nil? || (current_time - @cached_bookmark_time) > @bookmark_cache_ttl
1240
- require_relative 'bookmark'
1241
- bookmark = Bookmark.new
1242
- @cached_bookmarks = bookmark.list
1243
- @cached_bookmark_time = current_time
1244
- end
1245
- bookmarks = @cached_bookmarks
1246
-
1247
- # 起動ディレクトリを取得
1248
- start_dir = @directory_listing&.start_directory
1249
- start_dir_name = if start_dir
1250
- File.basename(start_dir)
1251
- else
1252
- "start"
1253
- end
1254
-
1255
- # ブックマーク一覧を作成(0.起動dir を先頭に追加)
1256
- bookmark_parts = ["0.#{start_dir_name}"]
1257
- unless bookmarks.empty?
1258
- bookmark_parts.concat(bookmarks.take(9).map.with_index(1) { |bm, idx| "#{idx}.#{bm[:name]}" })
1259
- end
1260
- bookmark_text = bookmark_parts.join(" ")
1261
-
1262
- # ステータス情報を作成
1263
- time_info = render_time ? "#{(render_time * 1000).round(1)}ms" : "-ms"
1264
-
1265
- # 右側の情報: 処理時間 | ?:help
1266
- right_info = "#{time_info} | ?:help"
1267
-
1268
- # ブックマーク一覧を利用可能な幅に収める
1269
- available_width = @screen_width - right_info.length - 3
1270
- if bookmark_text.length > available_width && available_width > 3
1271
- bookmark_text = bookmark_text[0...available_width - 3] + "..."
1272
- elsif available_width <= 3
1273
- bookmark_text = ""
1274
- end
1275
-
1276
- # フッタ全体を構築
1277
- padding = @screen_width - bookmark_text.length - right_info.length
1278
- footer_content = "#{bookmark_text}#{' ' * padding}#{right_info}"
1279
- footer_content = footer_content.ljust(@screen_width)[0...@screen_width]
1280
- print "\e[7m#{footer_content}\e[0m"
1281
- end
1282
- end
383
+ private
1283
384
 
1284
385
  # ノンブロッキング入力処理(ゲームループ用)
1285
386
  # IO.selectでタイムアウト付きで入力をチェック
@@ -1333,12 +434,27 @@ module Rufio
1333
434
  end
1334
435
  end
1335
436
 
1336
- # Tabキーでモード切り替え
1337
- if input == "\t"
437
+ # TabキーはFilesモードの時のみブックマーク循環移動
438
+ if input == "\t" && @tab_mode_manager.current_mode == :files
1338
439
  handle_tab_key
1339
440
  return true
1340
441
  end
1341
442
 
443
+ # 数字キー(1-9): Filesモード かつ フィルターモード外でブックマークジャンプ+ハイライト
444
+ if input&.match?(/^[1-9]$/) && @tab_mode_manager.current_mode == :files && !@keybind_handler.filter_active?
445
+ handle_bookmark_key(input.to_i)
446
+ return true
447
+ end
448
+
449
+ # Jobsモード中のモード切替キーをインターセプト(L:Logs, ?:Help, J:Files復帰)
450
+ if @in_job_mode
451
+ case input
452
+ when 'L' then apply_mode_change(:logs); return true
453
+ when '?' then apply_mode_change(:help); return true
454
+ when 'J' then apply_mode_change(:files); return true
455
+ end
456
+ end
457
+
1342
458
  # キーバインドハンドラーに処理を委譲
1343
459
  result = @keybind_handler.handle_key(input) if input
1344
460
 
@@ -1355,83 +471,39 @@ module Rufio
1355
471
  # 入力があったことを返す
1356
472
  true
1357
473
  end
1358
-
1359
- def handle_input
1360
- begin
1361
- input = STDIN.getch
1362
- rescue Errno::ENOTTY, Errno::ENODEV
1363
- # ターミナルでない環境(IDE等)では標準入力を使用
1364
- print "\nOperation: "
1365
- input = STDIN.gets
1366
- return 'q' if input.nil?
1367
- input = input.chomp.downcase
1368
- return input[0] if input.length > 0
1369
-
1370
- return 'q'
1371
- end
1372
-
1373
- # コマンドモードがアクティブな場合は、エスケープシーケンス処理をスキップ
1374
- # ESCキーをそのまま handle_command_input に渡す
1375
- if @command_mode_active
1376
- handle_command_input(input)
1377
- return
1378
- end
1379
-
1380
- # 特殊キーの処理(コマンドモード外のみ)
1381
- if input == "\e"
1382
- # エスケープシーケンスの処理
1383
- next_char = begin
1384
- STDIN.read_nonblock(1)
1385
- rescue StandardError
1386
- nil
1387
- end
1388
- if next_char == '['
1389
- arrow_key = begin
1390
- STDIN.read_nonblock(1)
1391
- rescue StandardError
1392
- nil
1393
- end
1394
- input = case arrow_key
1395
- when 'A' # 上矢印
1396
- 'k'
1397
- when 'B' # 下矢印
1398
- 'j'
1399
- when 'C' # 右矢印
1400
- 'l'
1401
- when 'D' # 左矢印
1402
- 'h'
1403
- else
1404
- "\e" # ESCキー(そのまま保持)
1405
- end
1406
- else
1407
- input = "\e" # ESCキー(そのまま保持)
1408
- end
1409
- end
1410
-
1411
- # キーバインドハンドラーに処理を委譲
1412
- result = @keybind_handler.handle_key(input)
1413
-
1414
- # 外部ターミナルアプリ(vim等)から戻った後は画面全体を再描画
1415
- if result == :needs_refresh
1416
- refresh_display
1417
- end
1418
-
1419
- # 終了処理(qキーのみ、確認ダイアログの結果を確認)
1420
- if input == 'q' && result == true
1421
- @running = false
474
+ # Tabキー: 次のブックマークへ循環移動
475
+ def handle_tab_key
476
+ next_idx = @keybind_handler.goto_next_bookmark
477
+ if next_idx
478
+ # display_index: 0=start_dir, 1..9=bookmarks(next_idx は 0-based bookmarks 配列)
479
+ @ui_renderer.set_highlighted_bookmark(next_idx + 1)
480
+ # ブックマークキャッシュを即時クリア(移動先を反映させる)
481
+ @ui_renderer.clear_bookmark_cache
1422
482
  end
1423
483
  end
1424
484
 
1425
- # Tabキーによるモード切り替え
1426
- def handle_tab_key
1427
- @tab_mode_manager.next_mode
1428
- apply_mode_change(@tab_mode_manager.current_mode)
485
+ # 数字キー(1-9): 指定番号のブックマークへジャンプ+ハイライト
486
+ def handle_bookmark_key(number)
487
+ result = @keybind_handler.goto_bookmark(number)
488
+ if result
489
+ # display_index = number(1.bookmark1, 2.bookmark2, ...)
490
+ @ui_renderer.set_highlighted_bookmark(number)
491
+ @ui_renderer.clear_bookmark_cache
492
+ end
1429
493
  end
1430
494
 
1431
- # Shift+Tabによる逆順モード切り替え
495
+ # Shift+Tab: Filesモードでは前のブックマークへ循環移動、それ以外はモード逆順切り替え
1432
496
  def handle_shift_tab
1433
- @tab_mode_manager.previous_mode
1434
- apply_mode_change(@tab_mode_manager.current_mode)
497
+ if @tab_mode_manager.current_mode == :files
498
+ prev_idx = @keybind_handler.goto_prev_bookmark
499
+ if prev_idx
500
+ @ui_renderer.set_highlighted_bookmark(prev_idx + 1)
501
+ @ui_renderer.clear_bookmark_cache
502
+ end
503
+ else
504
+ @tab_mode_manager.previous_mode
505
+ apply_mode_change(@tab_mode_manager.current_mode)
506
+ end
1435
507
  end
1436
508
 
1437
509
  # モード変更を適用