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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +34 -1
- data/README.md +1 -1
- data/README_ja.md +2 -0
- data/lib/rufio/bookmark_controller.rb +439 -0
- data/lib/rufio/config_loader.rb +66 -14
- data/lib/rufio/file_operation_controller.rb +497 -0
- data/lib/rufio/keybind_handler.rb +174 -1283
- data/lib/rufio/navigation_controller.rb +266 -0
- data/lib/rufio/search_controller.rb +91 -0
- data/lib/rufio/tab_mode_manager.rb +12 -0
- data/lib/rufio/terminal_ui.rb +94 -1022
- data/lib/rufio/ui_renderer.rb +765 -0
- data/lib/rufio/version.rb +1 -1
- data/lib/rufio/zoxide_integration.rb +11 -5
- data/lib/rufio.rb +5 -0
- metadata +7 -2
data/lib/rufio/terminal_ui.rb
CHANGED
|
@@ -6,9 +6,9 @@ require_relative 'text_utils'
|
|
|
6
6
|
module Rufio
|
|
7
7
|
class TerminalUI
|
|
8
8
|
# Layout constants
|
|
9
|
-
HEADER_HEIGHT =
|
|
9
|
+
HEADER_HEIGHT = 1 # Header占有行数(モードタブ+パス+バージョン 1行に統合)
|
|
10
10
|
FOOTER_HEIGHT = 1 # Footer占有行数(ブックマーク一覧 + ステータス情報)
|
|
11
|
-
HEADER_FOOTER_MARGIN =
|
|
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 =
|
|
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 &&
|
|
292
|
-
|
|
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
|
-
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
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
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
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
|
-
#
|
|
1426
|
-
def
|
|
1427
|
-
@
|
|
1428
|
-
|
|
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.
|
|
1434
|
-
|
|
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
|
# モード変更を適用
|