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
|
@@ -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
|