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.
@@ -0,0 +1,765 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'text_utils'
4
+
5
+ module Rufio
6
+ # UIレンダリング専用クラス
7
+ # TerminalUI から draw_*_to_buffer 系メソッドを分離し、単一責任原則に準拠
8
+ # - ディレクトリリスト・ファイルプレビュー・フッター・タブ描画を担当
9
+ # - キャッシュ(プレビュー・ブックマーク)を管理
10
+ # - シンタックスハイライト(bat 連携)を担当
11
+ class UIRenderer
12
+ # Layout constants
13
+ HEADER_FOOTER_MARGIN = 2 # Header(1行) + Footer(1行) 分のマージン
14
+ CONTENT_START_LINE = 1 # コンテンツ開始行(フッタ1行: Y=0)
15
+ CURSOR_OFFSET = 1 # カーソル位置のオフセット
16
+ ICON_SIZE_PADDING = 12 # アイコン、選択マーク、サイズ情報分
17
+ BOOKMARK_HIGHLIGHT_DURATION = 0.5 # ブックマークハイライト表示時間(秒)
18
+ TAB_SEPARATOR = ">" # タブ間セパレータ
19
+
20
+ # File display constants
21
+ KILOBYTE = 1024
22
+ MEGABYTE = KILOBYTE * 1024
23
+ GIGABYTE = MEGABYTE * 1024
24
+
25
+ attr_accessor :keybind_handler, :directory_listing, :file_preview
26
+ attr_accessor :background_executor, :test_mode
27
+ attr_accessor :completion_lamp_message, :completion_lamp_time
28
+ attr_reader :tab_mode_manager, :highlight_updated
29
+
30
+ def preview_enabled?
31
+ @preview_enabled
32
+ end
33
+
34
+ def initialize(screen_width:, screen_height:,
35
+ keybind_handler: nil, directory_listing: nil,
36
+ file_preview: nil, background_executor: nil,
37
+ test_mode: false,
38
+ left_panel_ratio: 0.5,
39
+ preview_enabled: true)
40
+ @screen_width = screen_width
41
+ @screen_height = screen_height
42
+ @keybind_handler = keybind_handler
43
+ @directory_listing = directory_listing
44
+ @file_preview = file_preview
45
+ @background_executor = background_executor
46
+ @test_mode = test_mode
47
+ @left_panel_ratio = left_panel_ratio
48
+ @preview_enabled = preview_enabled
49
+
50
+ # Preview cache
51
+ @preview_cache = {}
52
+ @last_preview_path = nil
53
+
54
+ # Syntax highlighter(bat が利用可能な場合のみ動作)
55
+ @syntax_highlighter = SyntaxHighlighter.new
56
+ @highlight_updated = false
57
+
58
+ # Bookmark cache(毎フレームのファイルI/Oを回避)
59
+ @cached_bookmarks = nil
60
+ @cached_bookmark_time = nil
61
+ @bookmark_cache_ttl = 1.0
62
+
63
+ # Bookmark highlight (Tab ジャンプ時に 500ms ハイライト)
64
+ @highlighted_bookmark_index = nil
65
+ @highlighted_bookmark_time = nil
66
+
67
+ # Completion lamp
68
+ @completion_lamp_message = nil
69
+ @completion_lamp_time = nil
70
+
71
+ # Tab mode manager
72
+ @tab_mode_manager = TabModeManager.new
73
+ end
74
+
75
+ # ============================
76
+ # Cache management
77
+ # ============================
78
+
79
+ def clear_preview_cache
80
+ @preview_cache.clear
81
+ @last_preview_path = nil
82
+ end
83
+
84
+ def clear_bookmark_cache
85
+ @cached_bookmarks = nil
86
+ @cached_bookmark_time = nil
87
+ end
88
+
89
+ def highlight_updated?
90
+ @highlight_updated
91
+ end
92
+
93
+ def reset_highlight_updated
94
+ @highlight_updated = false
95
+ end
96
+
97
+ def set_highlighted_bookmark(index)
98
+ @highlighted_bookmark_index = index
99
+ @highlighted_bookmark_time = Time.now
100
+ end
101
+
102
+ def clear_highlighted_bookmark
103
+ @highlighted_bookmark_index = nil
104
+ @highlighted_bookmark_time = nil
105
+ end
106
+
107
+ # ブックマークハイライトが期限切れかどうか
108
+ def bookmark_highlight_expired?
109
+ return false unless @highlighted_bookmark_index && @highlighted_bookmark_time
110
+
111
+ (Time.now - @highlighted_bookmark_time) >= BOOKMARK_HIGHLIGHT_DURATION
112
+ end
113
+
114
+ # ============================
115
+ # 全体描画エントリーポイント
116
+ # ============================
117
+
118
+ # Screen バッファに全体を描画する
119
+ # @param screen [Screen] 描画対象のスクリーンバッファ
120
+ # @param notification_message [String, nil] 通知メッセージ
121
+ # @param fps [Float, nil] FPS(テストモード時のみ表示)
122
+ # @param in_job_mode [Boolean] ジョブモード中かどうか
123
+ # @param job_manager [JobManager, nil] ジョブマネージャー
124
+ # @param job_mode_instance [JobMode, nil] ジョブモードインスタンス
125
+ def draw_screen(screen, notification_message: nil, fps: nil,
126
+ in_job_mode: false, job_manager: nil, job_mode_instance: nil)
127
+ content_height = @screen_height - HEADER_FOOTER_MARGIN
128
+
129
+ if in_job_mode
130
+ # ジョブモード: フッタ y=0(上部)、コンテンツ y=1〜h-2、統合行 y=h-1(下部)
131
+ draw_job_footer_to_buffer(screen, 0, job_manager)
132
+ draw_job_list_to_buffer(screen, content_height, job_manager, job_mode_instance)
133
+ draw_mode_tabs_to_buffer(screen, @screen_height - 1)
134
+ else
135
+ # 通常モード: フッタ y=0(上部)、コンテンツ y=1〜h-2、統合行 y=h-1(下部)
136
+ draw_footer_to_buffer(screen, 0, fps)
137
+
138
+ entries = get_display_entries
139
+ selected_entry = entries[@keybind_handler.current_index]
140
+
141
+ left_width = (@screen_width * @left_panel_ratio).to_i
142
+ right_width = @screen_width - left_width
143
+
144
+ draw_directory_list_to_buffer(screen, entries, left_width, content_height)
145
+ draw_file_preview_to_buffer(screen, selected_entry, right_width, content_height, left_width)
146
+
147
+ draw_mode_tabs_to_buffer(screen, @screen_height - 1)
148
+ end
149
+
150
+ # 通知メッセージがある場合は表示
151
+ if notification_message
152
+ notification_line = @screen_height - 1
153
+ message_display = " #{notification_message} "
154
+ message_display = message_display[0...(@screen_width - 3)] + "..." if message_display.length > @screen_width
155
+ screen.put_string(0, notification_line, message_display.ljust(@screen_width), fg: "\e[7m")
156
+ end
157
+ end
158
+
159
+ # 後方互換性のためのエイリアス(TerminalUI のシグネチャに合わせる)
160
+ def draw_screen_to_buffer(screen, notification_message = nil, fps = nil,
161
+ in_job_mode: false, job_manager: nil, job_mode_instance: nil)
162
+ draw_screen(screen,
163
+ notification_message: notification_message,
164
+ fps: fps,
165
+ in_job_mode: in_job_mode,
166
+ job_manager: job_manager,
167
+ job_mode_instance: job_mode_instance)
168
+ end
169
+
170
+ # ============================
171
+ # ファイルサイズ表示
172
+ # ============================
173
+
174
+ def format_size(size)
175
+ return ' ' if size == 0
176
+
177
+ if size < KILOBYTE
178
+ "#{size}B".rjust(6)
179
+ elsif size < MEGABYTE
180
+ "#{(size / KILOBYTE.to_f).round(1)}K".rjust(6)
181
+ elsif size < GIGABYTE
182
+ "#{(size / MEGABYTE.to_f).round(1)}M".rjust(6)
183
+ else
184
+ "#{(size / GIGABYTE.to_f).round(1)}G".rjust(6)
185
+ end
186
+ end
187
+
188
+ # ============================
189
+ # エントリ表示情報
190
+ # ============================
191
+
192
+ def get_entry_display_info(entry)
193
+ colors = ConfigLoader.colors
194
+
195
+ case entry[:type]
196
+ when 'directory'
197
+ color_code = ColorHelper.color_to_ansi(colors[:directory])
198
+ ['📁', color_code]
199
+ when 'executable'
200
+ color_code = ColorHelper.color_to_ansi(colors[:executable])
201
+ ['⚡', color_code]
202
+ else
203
+ case File.extname(entry[:name]).downcase
204
+ when '.rb'
205
+ ['💎', "\e[31m"] # 赤
206
+ when '.js', '.ts'
207
+ ['📜', "\e[33m"] # 黄
208
+ when '.txt', '.md'
209
+ color_code = ColorHelper.color_to_ansi(colors[:file])
210
+ ['📄', color_code]
211
+ else
212
+ color_code = ColorHelper.color_to_ansi(colors[:file])
213
+ ['📄', color_code]
214
+ end
215
+ end
216
+ end
217
+
218
+ # ============================
219
+ # プレビュー行抽出
220
+ # ============================
221
+
222
+ # FilePreview の結果ハッシュからプレーンテキスト行を抽出する
223
+ def extract_preview_lines(preview)
224
+ case preview[:type]
225
+ when 'text', 'code'
226
+ preview[:lines]
227
+ when 'binary'
228
+ ["(#{ConfigLoader.message('file.binary_file')})", ConfigLoader.message('file.cannot_preview')]
229
+ when 'error'
230
+ ["#{ConfigLoader.message('file.error_prefix')}:", preview[:message]]
231
+ else
232
+ ["(#{ConfigLoader.message('file.cannot_preview')})"]
233
+ end
234
+ rescue StandardError
235
+ ["(#{ConfigLoader.message('file.preview_error')})"]
236
+ end
237
+
238
+ # ============================
239
+ # ディレクトリリスト描画
240
+ # ============================
241
+
242
+ def draw_directory_list_to_buffer(screen, entries, width, height)
243
+ start_index = [@keybind_handler.current_index - height / 2, 0].max
244
+
245
+ (0...height).each do |i|
246
+ entry_index = start_index + i
247
+ line_num = i + CONTENT_START_LINE
248
+
249
+ if entry_index < entries.length
250
+ entry = entries[entry_index]
251
+ is_selected = entry_index == @keybind_handler.current_index
252
+
253
+ draw_entry_line_to_buffer(screen, entry, width, is_selected, 0, line_num)
254
+ else
255
+ # 空行
256
+ safe_width = [width - CURSOR_OFFSET, (@screen_width * @left_panel_ratio).to_i - CURSOR_OFFSET].min
257
+ screen.put_string(0, line_num, ' ' * safe_width)
258
+ end
259
+ end
260
+ end
261
+
262
+ def draw_entry_line_to_buffer(screen, entry, width, is_selected, x, y)
263
+ # アイコンと色の設定
264
+ icon, color = get_entry_display_info(entry)
265
+
266
+ # 左ペイン専用の安全な幅を計算
267
+ safe_width = [width - CURSOR_OFFSET, (@screen_width * @left_panel_ratio).to_i - CURSOR_OFFSET].min
268
+
269
+ # 選択マークの追加
270
+ selection_mark = @keybind_handler.is_selected?(entry[:name]) ? "✓ " : " "
271
+
272
+ # ファイル名(必要に応じて切り詰め)
273
+ name = entry[:name]
274
+ max_name_length = safe_width - ICON_SIZE_PADDING
275
+ name = name[0...max_name_length - 3] + '...' if max_name_length > 0 && name.length > max_name_length
276
+
277
+ # サイズ情報
278
+ size_info = format_size(entry[:size])
279
+
280
+ # 行の内容を構築
281
+ content_without_size = "#{selection_mark}#{icon} #{name}"
282
+ available_for_content = safe_width - size_info.length
283
+
284
+ line_content = if available_for_content > 0
285
+ content_without_size.ljust(available_for_content) + size_info
286
+ else
287
+ content_without_size
288
+ end
289
+
290
+ # 確実に safe_width を超えないよう切り詰め
291
+ line_content = line_content[0...safe_width]
292
+
293
+ # 色を決定
294
+ if is_selected
295
+ fg_color = ColorHelper.color_to_selected_ansi(ConfigLoader.colors[:selected])
296
+ screen.put_string(x, y, line_content, fg: fg_color)
297
+ elsif @keybind_handler.is_selected?(entry[:name])
298
+ # 選択されたアイテムは緑背景、黒文字
299
+ screen.put_string(x, y, line_content, fg: "\e[42m\e[30m")
300
+ else
301
+ screen.put_string(x, y, line_content, fg: color)
302
+ end
303
+ end
304
+
305
+ # ============================
306
+ # ファイルプレビュー描画
307
+ # ============================
308
+
309
+ def draw_file_preview_to_buffer(screen, selected_entry, width, height, left_offset)
310
+ # 事前計算
311
+ cursor_position = left_offset + CURSOR_OFFSET
312
+ max_chars_from_cursor = @screen_width - cursor_position
313
+ safe_width = [max_chars_from_cursor - 2, width - 2, 0].max
314
+
315
+ # プレビューコンテンツをキャッシュから取得
316
+ preview_content = nil
317
+ wrapped_lines = nil
318
+ highlighted_wrapped_lines = nil
319
+
320
+ if selected_entry && selected_entry[:type] == 'file'
321
+ if @last_preview_path != selected_entry[:path]
322
+ full_preview = @file_preview.preview_file(selected_entry[:path])
323
+ preview_content = extract_preview_lines(full_preview)
324
+ @preview_cache[selected_entry[:path]] = {
325
+ content: preview_content,
326
+ preview_data: full_preview,
327
+ highlighted: nil,
328
+ wrapped: {},
329
+ highlighted_wrapped: {}
330
+ }
331
+ @last_preview_path = selected_entry[:path]
332
+ else
333
+ cache_entry = @preview_cache[selected_entry[:path]]
334
+ preview_content = cache_entry[:content] if cache_entry
335
+ end
336
+
337
+ # bat が利用可能な場合はシンタックスハイライトを取得(非同期)
338
+ if @syntax_highlighter&.available? && preview_content
339
+ cache_entry = @preview_cache[selected_entry[:path]]
340
+ if cache_entry
341
+ preview_data = cache_entry[:preview_data]
342
+ if preview_data && preview_data[:type] == 'code' && preview_data[:encoding] == 'UTF-8'
343
+ if cache_entry[:highlighted].nil?
344
+ cache_entry[:highlighted] = false
345
+ file_path = selected_entry[:path]
346
+ @syntax_highlighter.highlight_async(file_path) do |lines|
347
+ if (ce = @preview_cache[file_path])
348
+ ce[:highlighted] = lines
349
+ ce[:highlighted_wrapped] = {}
350
+ end
351
+ @highlight_updated = true
352
+ end
353
+ end
354
+
355
+ highlighted = cache_entry[:highlighted]
356
+ if highlighted.is_a?(Array) && !highlighted.empty? && safe_width > 0
357
+ if cache_entry[:highlighted_wrapped][safe_width]
358
+ highlighted_wrapped_lines = cache_entry[:highlighted_wrapped][safe_width]
359
+ else
360
+ hl_wrapped = highlighted.flat_map do |hl_line|
361
+ tokens = AnsiLineParser.parse(hl_line)
362
+ tokens.empty? ? [[]] : AnsiLineParser.wrap(tokens, safe_width - 1)
363
+ end
364
+ cache_entry[:highlighted_wrapped][safe_width] = hl_wrapped
365
+ highlighted_wrapped_lines = hl_wrapped
366
+ end
367
+ end
368
+ end
369
+ end
370
+ end
371
+
372
+ # プレーンテキストの折り返し(ハイライトなしのフォールバック)
373
+ if preview_content && safe_width > 0 && highlighted_wrapped_lines.nil?
374
+ cache_entry = @preview_cache[selected_entry[:path]]
375
+ if cache_entry && cache_entry[:wrapped][safe_width]
376
+ wrapped_lines = cache_entry[:wrapped][safe_width]
377
+ else
378
+ wrapped_lines = TextUtils.wrap_preview_lines(preview_content, safe_width - 1)
379
+ cache_entry[:wrapped][safe_width] = wrapped_lines if cache_entry
380
+ end
381
+ end
382
+ end
383
+
384
+ content_x = cursor_position + 1
385
+
386
+ (0...height).each do |i|
387
+ line_num = i + CONTENT_START_LINE
388
+
389
+ # 区切り線
390
+ screen.put(cursor_position, line_num, '│')
391
+
392
+ next if safe_width <= 0
393
+
394
+ if selected_entry && i == 0
395
+ # プレビューヘッダー
396
+ header = " #{selected_entry[:name]} "
397
+ header += "[PREVIEW MODE]" if @keybind_handler&.preview_focused?
398
+ header = TextUtils.truncate_to_width(header, safe_width) if TextUtils.display_width(header) > safe_width
399
+ remaining_space = safe_width - TextUtils.display_width(header)
400
+ header += ' ' * remaining_space if remaining_space > 0
401
+ screen.put_string(content_x, line_num, header)
402
+
403
+ elsif i >= 2 && highlighted_wrapped_lines
404
+ # シンタックスハイライト付きコンテンツ
405
+ scroll_offset = @keybind_handler&.preview_scroll_offset || 0
406
+ display_line_index = i - 2 + scroll_offset
407
+
408
+ if display_line_index < highlighted_wrapped_lines.length
409
+ draw_highlighted_line_to_buffer(screen, content_x, line_num,
410
+ highlighted_wrapped_lines[display_line_index], safe_width)
411
+ else
412
+ screen.put_string(content_x, line_num, ' ' * safe_width)
413
+ end
414
+
415
+ elsif i >= 2 && wrapped_lines
416
+ # プレーンテキストコンテンツ
417
+ scroll_offset = @keybind_handler&.preview_scroll_offset || 0
418
+ display_line_index = i - 2 + scroll_offset
419
+
420
+ content_to_print = if display_line_index < wrapped_lines.length
421
+ " #{wrapped_lines[display_line_index] || ''}"
422
+ else
423
+ ' '
424
+ end
425
+ content_to_print = TextUtils.truncate_to_width(content_to_print, safe_width) if TextUtils.display_width(content_to_print) > safe_width
426
+ remaining_space = safe_width - TextUtils.display_width(content_to_print)
427
+ content_to_print += ' ' * remaining_space if remaining_space > 0
428
+ screen.put_string(content_x, line_num, content_to_print)
429
+
430
+ else
431
+ screen.put_string(content_x, line_num, ' ' * safe_width)
432
+ end
433
+ end
434
+ end
435
+
436
+ # ハイライト済みトークン列を1行分 Screen バッファに描画する
437
+ def draw_highlighted_line_to_buffer(screen, x, y, tokens, max_width)
438
+ current_x = x
439
+ max_x = x + max_width
440
+
441
+ # 先頭スペース
442
+ if current_x < max_x
443
+ screen.put(current_x, y, ' ')
444
+ current_x += 1
445
+ end
446
+
447
+ # トークンを描画
448
+ tokens&.each do |token|
449
+ break if current_x >= max_x
450
+ token[:text].each_char do |char|
451
+ char_w = TextUtils.char_width(char)
452
+ break if current_x + char_w > max_x
453
+ screen.put(current_x, y, char, fg: token[:fg])
454
+ current_x += char_w
455
+ end
456
+ end
457
+
458
+ # 残りをスペースで埋める
459
+ while current_x < max_x
460
+ screen.put(current_x, y, ' ')
461
+ current_x += 1
462
+ end
463
+ end
464
+
465
+ # ============================
466
+ # フッター描画
467
+ # ============================
468
+
469
+ def draw_footer_to_buffer(screen, y, fps = nil)
470
+ if @keybind_handler.filter_active?
471
+ if @keybind_handler.instance_variable_get(:@filter_mode)
472
+ help_text = "Filter mode: Type to filter, ESC to clear, Enter to apply, Backspace to delete"
473
+ else
474
+ help_text = "Filtered view active - Space to edit filter, ESC to clear filter"
475
+ end
476
+ footer_content = help_text.ljust(@screen_width)[0...@screen_width]
477
+ screen.put_string(0, y, footer_content, fg: "\e[7m")
478
+ else
479
+ # ブックマークをキャッシュ(Tab移動と同じソース:keybind_handler経由で取得)
480
+ current_time = Time.now
481
+ if @cached_bookmarks.nil? || @cached_bookmark_time.nil? || (current_time - @cached_bookmark_time) > @bookmark_cache_ttl
482
+ @cached_bookmarks = @keybind_handler.bookmark_list
483
+ @cached_bookmark_time = current_time
484
+ end
485
+ bookmarks = @cached_bookmarks
486
+
487
+ # 起動ディレクトリを取得
488
+ start_dir = @directory_listing&.start_directory
489
+ start_dir_name = if start_dir
490
+ File.basename(start_dir)
491
+ else
492
+ "start"
493
+ end
494
+
495
+ # ブックマーク一覧を作成(0.起動dir を先頭に追加)
496
+ bookmark_parts = ["0.#{start_dir_name}"]
497
+ unless bookmarks.empty?
498
+ bookmark_parts.concat(bookmarks.take(9).map.with_index(1) { |bm, idx| "#{idx}.#{bm[:name]}" })
499
+ end
500
+ bookmark_text = bookmark_parts.join(" │ ")
501
+
502
+ # 右側の情報
503
+ right_parts = []
504
+
505
+ # ジョブ数を表示(ジョブがある場合のみ)
506
+ if @keybind_handler.has_jobs?
507
+ job_text = @keybind_handler.job_status_bar_text
508
+ right_parts << "[#{job_text}]" if job_text
509
+ end
510
+
511
+ # バックグラウンドコマンドの実行状態をランプで表示
512
+ if @background_executor
513
+ if @background_executor.running?
514
+ command_name = @background_executor.current_command || "処理中"
515
+ right_parts << "\e[32m🔄\e[0m #{command_name}"
516
+ elsif @completion_lamp_message && @completion_lamp_time
517
+ if (Time.now - @completion_lamp_time) < 3.0
518
+ right_parts << @completion_lamp_message
519
+ else
520
+ @completion_lamp_message = nil
521
+ @completion_lamp_time = nil
522
+ end
523
+ end
524
+ end
525
+
526
+ # FPS表示(test modeの時のみ)
527
+ right_parts << "#{fps.round(1)} FPS" if @test_mode && fps
528
+
529
+ right_info = right_parts.join(" | ")
530
+
531
+ # ブックマーク一覧を利用可能な幅に収める
532
+ if right_info.empty?
533
+ available_width = @screen_width
534
+ else
535
+ available_width = @screen_width - right_info.length - 3
536
+ end
537
+ if bookmark_text.length > available_width && available_width > 3
538
+ bookmark_text = bookmark_text[0...available_width - 3] + "..."
539
+ elsif available_width <= 3
540
+ bookmark_text = ""
541
+ end
542
+
543
+ # フッタ全体を構築
544
+ if right_info.empty?
545
+ footer_content = bookmark_text.ljust(@screen_width)[0...@screen_width]
546
+ else
547
+ padding = @screen_width - bookmark_text.length - right_info.length
548
+ footer_content = "#{bookmark_text}#{' ' * padding}#{right_info}"
549
+ footer_content = footer_content.ljust(@screen_width)[0...@screen_width]
550
+ end
551
+ screen.put_string(0, y, footer_content, fg: "\e[90m")
552
+
553
+ # Tab ジャンプ時:対象ブックマークを 500ms ハイライト(セカンドパス)
554
+ if @highlighted_bookmark_index && !bookmark_highlight_expired? && available_width > 3
555
+ highlight_idx = @highlighted_bookmark_index
556
+ if highlight_idx < bookmark_parts.length
557
+ separator_len = 3 # " │ "
558
+ x_pos = bookmark_parts[0...highlight_idx].sum { |p| p.length + separator_len }
559
+ part_text = bookmark_parts[highlight_idx]
560
+ if x_pos < available_width
561
+ visible_len = [part_text.length, available_width - x_pos].min
562
+ screen.put_string(x_pos, y, part_text[0...visible_len], fg: "\e[1;36m")
563
+ end
564
+ end
565
+ end
566
+ end
567
+ end
568
+
569
+ # ============================
570
+ # モードタブ描画
571
+ # ============================
572
+
573
+ def draw_mode_tabs_to_buffer(screen, y)
574
+ # タブモードマネージャの状態を同期
575
+ sync_tab_mode_with_keybind_handler
576
+
577
+ current_x = 0
578
+ modes = @tab_mode_manager.available_modes
579
+ labels = @tab_mode_manager.mode_labels
580
+ keys = @tab_mode_manager.mode_keys
581
+ current_mode = @tab_mode_manager.current_mode
582
+
583
+ modes.each_with_index do |mode, index|
584
+ key = keys[mode]
585
+ label = key ? " #{key}:#{labels[mode]} " : " #{labels[mode]} "
586
+
587
+ if mode == current_mode
588
+ label.each_char do |char|
589
+ screen.put(current_x, y, char, fg: "\e[30m\e[1m", bg: "\e[46m")
590
+ current_x += 1
591
+ end
592
+ else
593
+ label.each_char do |char|
594
+ screen.put(current_x, y, char, fg: "\e[90m")
595
+ current_x += 1
596
+ end
597
+ end
598
+
599
+ # セパレータ
600
+ if index < modes.length - 1
601
+ if mode == current_mode
602
+ screen.put(current_x, y, "\uE0B0", fg: "\e[36m")
603
+ else
604
+ screen.put(current_x, y, TAB_SEPARATOR, fg: "\e[90m")
605
+ end
606
+ current_x += 1
607
+ end
608
+ end
609
+
610
+ # パスとバージョン情報を行末に追加
611
+ current_path = @directory_listing.current_path
612
+ version_str = " rufio v#{VERSION}"
613
+ version_w = version_str.length
614
+
615
+ remaining_w = @screen_width - current_x
616
+ path_display_w = remaining_w - 2 - version_w
617
+
618
+ if path_display_w >= 3
619
+ arrow_fg = modes.last == current_mode ? "\e[36m" : "\e[90m"
620
+ screen.put(current_x, y, TAB_SEPARATOR, fg: arrow_fg)
621
+ current_x += 1
622
+
623
+ path_end = @screen_width - 1 - version_w
624
+ path_str = " #{current_path} "
625
+ path_str.each_char do |char|
626
+ break if current_x >= path_end
627
+ char_w = TextUtils.display_width(char)
628
+ break if current_x + char_w > path_end
629
+ screen.put(current_x, y, char, fg: "\e[90m")
630
+ current_x += char_w
631
+ end
632
+
633
+ while current_x < path_end
634
+ screen.put(current_x, y, ' ')
635
+ current_x += 1
636
+ end
637
+
638
+ screen.put(current_x, y, "\uE0B2", fg: "\e[36m")
639
+ current_x += 1
640
+
641
+ version_str.each_char do |char|
642
+ break if current_x >= @screen_width
643
+ screen.put(current_x, y, char, fg: "\e[30m\e[1m", bg: "\e[46m")
644
+ current_x += 1
645
+ end
646
+ end
647
+
648
+ # 残りをスペースで埋める
649
+ while current_x < @screen_width
650
+ screen.put(current_x, y, ' ')
651
+ current_x += 1
652
+ end
653
+ end
654
+
655
+ # ============================
656
+ # ジョブモード描画
657
+ # ============================
658
+
659
+ def draw_job_list_to_buffer(screen, height, job_manager, job_mode_instance)
660
+ return unless job_manager
661
+
662
+ jobs = job_manager.jobs
663
+ selected_index = job_mode_instance&.selected_index || 0
664
+
665
+ (0...height).each do |i|
666
+ line_num = i + CONTENT_START_LINE
667
+
668
+ if i < jobs.length
669
+ job = jobs[i]
670
+ draw_job_line_to_buffer(screen, job, i == selected_index, line_num)
671
+ else
672
+ screen.put_string(0, line_num, ' ' * @screen_width)
673
+ end
674
+ end
675
+ end
676
+
677
+ def draw_job_line_to_buffer(screen, job, is_selected, y)
678
+ icon = job.status_icon
679
+ name = job.name
680
+ path = "(#{job.path})"
681
+ duration = job.formatted_duration
682
+ duration_text = duration.empty? ? "" : "[#{duration}]"
683
+
684
+ status_text = case job.status
685
+ when :running then "Running"
686
+ when :completed then "Done"
687
+ when :failed then "Failed"
688
+ when :waiting then "Waiting"
689
+ when :cancelled then "Cancelled"
690
+ else ""
691
+ end
692
+
693
+ status_color = case job.status
694
+ when :running then "\e[33m"
695
+ when :completed then "\e[32m"
696
+ when :failed then "\e[31m"
697
+ else "\e[37m"
698
+ end
699
+
700
+ line_content = "#{icon} #{name} #{path}".ljust(40)
701
+ line_content += "#{duration_text.ljust(12)} #{status_text}"
702
+ line_content = line_content[0...@screen_width].ljust(@screen_width)
703
+
704
+ if is_selected
705
+ line_content.each_char.with_index do |char, x|
706
+ screen.put(x, y, char, fg: "\e[30m", bg: "\e[47m")
707
+ end
708
+ else
709
+ line_content.each_char.with_index do |char, x|
710
+ screen.put(x, y, char, fg: status_color)
711
+ end
712
+ end
713
+ end
714
+
715
+ def draw_job_footer_to_buffer(screen, y, job_manager)
716
+ job_count = job_manager&.job_count || 0
717
+ help_text = "[Space] View Log | [x] Cancel | [Tab] Switch Mode | Jobs: #{job_count}"
718
+ footer_content = help_text.center(@screen_width)[0...@screen_width]
719
+
720
+ footer_content.each_char.with_index do |char, x|
721
+ screen.put(x, y, char, fg: "\e[30m", bg: "\e[47m")
722
+ end
723
+ end
724
+
725
+ # ============================
726
+ # ヘルパーメソッド
727
+ # ============================
728
+
729
+ private
730
+
731
+ def get_display_entries
732
+ entries = if @keybind_handler.filter_active?
733
+ all_entries = @directory_listing.list_entries
734
+ query = @keybind_handler.filter_query.downcase
735
+ query.empty? ? all_entries : all_entries.select { |entry| entry[:name].downcase.include?(query) }
736
+ else
737
+ @directory_listing.list_entries
738
+ end
739
+
740
+ # ヘルプモードとLogsモードでは..を非表示にする
741
+ if @keybind_handler.help_mode? || @keybind_handler.log_viewer_mode?
742
+ entries.reject { |entry| entry[:name] == '..' }
743
+ else
744
+ entries
745
+ end
746
+ end
747
+
748
+ # キーバインドハンドラの状態とタブモードを同期
749
+ def sync_tab_mode_with_keybind_handler
750
+ return unless @keybind_handler
751
+
752
+ current_mode = if @keybind_handler.in_job_mode?
753
+ :jobs
754
+ elsif @keybind_handler.help_mode?
755
+ :help
756
+ elsif @keybind_handler.log_viewer_mode?
757
+ :logs
758
+ else
759
+ :files
760
+ end
761
+
762
+ @tab_mode_manager.switch_to(current_mode) if @tab_mode_manager.current_mode != current_mode
763
+ end
764
+ end
765
+ end