rufio 0.9.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 +7 -0
- data/CHANGELOG.md +188 -0
- data/CHANGELOG_v0.4.0.md +146 -0
- data/CHANGELOG_v0.5.0.md +26 -0
- data/CHANGELOG_v0.6.0.md +182 -0
- data/CHANGELOG_v0.7.0.md +280 -0
- data/CHANGELOG_v0.8.0.md +267 -0
- data/CHANGELOG_v0.9.0.md +279 -0
- data/README.md +631 -0
- data/README_EN.md +561 -0
- data/Rakefile +156 -0
- data/bin/rufio +34 -0
- data/config_example.rb +88 -0
- data/docs/PLUGIN_GUIDE.md +431 -0
- data/docs/plugin_example.rb +119 -0
- data/lib/rufio/application.rb +32 -0
- data/lib/rufio/bookmark.rb +115 -0
- data/lib/rufio/bookmark_manager.rb +173 -0
- data/lib/rufio/color_helper.rb +150 -0
- data/lib/rufio/command_mode.rb +72 -0
- data/lib/rufio/command_mode_ui.rb +168 -0
- data/lib/rufio/config.rb +199 -0
- data/lib/rufio/config_loader.rb +110 -0
- data/lib/rufio/dialog_renderer.rb +127 -0
- data/lib/rufio/directory_listing.rb +113 -0
- data/lib/rufio/file_opener.rb +140 -0
- data/lib/rufio/file_operations.rb +231 -0
- data/lib/rufio/file_preview.rb +200 -0
- data/lib/rufio/filter_manager.rb +114 -0
- data/lib/rufio/health_checker.rb +246 -0
- data/lib/rufio/keybind_handler.rb +828 -0
- data/lib/rufio/logger.rb +103 -0
- data/lib/rufio/plugin.rb +89 -0
- data/lib/rufio/plugin_config.rb +59 -0
- data/lib/rufio/plugin_manager.rb +84 -0
- data/lib/rufio/plugins/file_operations.rb +44 -0
- data/lib/rufio/selection_manager.rb +79 -0
- data/lib/rufio/terminal_ui.rb +630 -0
- data/lib/rufio/text_utils.rb +108 -0
- data/lib/rufio/version.rb +5 -0
- data/lib/rufio/zoxide_integration.rb +188 -0
- data/lib/rufio.rb +33 -0
- data/publish_gem.zsh +131 -0
- data/rufio.gemspec +40 -0
- data/test_delete/test1.txt +1 -0
- data/test_delete/test2.txt +1 -0
- metadata +189 -0
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'io/console'
|
|
4
|
+
|
|
5
|
+
module Rufio
|
|
6
|
+
class TerminalUI
|
|
7
|
+
# Layout constants
|
|
8
|
+
HEADER_HEIGHT = 2 # Header占有行数
|
|
9
|
+
FOOTER_HEIGHT = 1 # Footer占有行数
|
|
10
|
+
HEADER_FOOTER_MARGIN = 4 # Header + Footer分のマージン
|
|
11
|
+
|
|
12
|
+
# Panel layout ratios
|
|
13
|
+
LEFT_PANEL_RATIO = 0.5 # 左パネルの幅比率
|
|
14
|
+
RIGHT_PANEL_RATIO = 1.0 - LEFT_PANEL_RATIO
|
|
15
|
+
|
|
16
|
+
# Display constants
|
|
17
|
+
DEFAULT_SCREEN_WIDTH = 80 # デフォルト画面幅
|
|
18
|
+
DEFAULT_SCREEN_HEIGHT = 24 # デフォルト画面高さ
|
|
19
|
+
HEADER_PADDING = 2 # ヘッダーのパディング
|
|
20
|
+
BASE_INFO_RESERVED_WIDTH = 20 # ベースディレクトリ表示の予約幅
|
|
21
|
+
BASE_INFO_MIN_WIDTH = 10 # ベースディレクトリ表示の最小幅
|
|
22
|
+
FILTER_TEXT_RESERVED = 15 # フィルタテキスト表示の予約幅
|
|
23
|
+
|
|
24
|
+
# File display constants
|
|
25
|
+
ICON_SIZE_PADDING = 12 # アイコン、選択マーク、サイズ情報分
|
|
26
|
+
CURSOR_OFFSET = 1 # カーソル位置のオフセット
|
|
27
|
+
|
|
28
|
+
# Size display constants (bytes)
|
|
29
|
+
KILOBYTE = 1024
|
|
30
|
+
MEGABYTE = KILOBYTE * 1024
|
|
31
|
+
GIGABYTE = MEGABYTE * 1024
|
|
32
|
+
|
|
33
|
+
# Line offsets
|
|
34
|
+
CONTENT_START_LINE = 3 # コンテンツ開始行(ヘッダー2行スキップ)
|
|
35
|
+
|
|
36
|
+
def initialize
|
|
37
|
+
console = IO.console
|
|
38
|
+
if console
|
|
39
|
+
@screen_width, @screen_height = console.winsize.reverse
|
|
40
|
+
else
|
|
41
|
+
# fallback values (for test environments etc.)
|
|
42
|
+
@screen_width = DEFAULT_SCREEN_WIDTH
|
|
43
|
+
@screen_height = DEFAULT_SCREEN_HEIGHT
|
|
44
|
+
end
|
|
45
|
+
@running = false
|
|
46
|
+
@command_mode_active = false
|
|
47
|
+
@command_input = ""
|
|
48
|
+
@command_mode = CommandMode.new
|
|
49
|
+
@dialog_renderer = DialogRenderer.new
|
|
50
|
+
@command_mode_ui = CommandModeUI.new(@command_mode, @dialog_renderer)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def start(directory_listing, keybind_handler, file_preview)
|
|
54
|
+
@directory_listing = directory_listing
|
|
55
|
+
@keybind_handler = keybind_handler
|
|
56
|
+
@file_preview = file_preview
|
|
57
|
+
@keybind_handler.set_directory_listing(@directory_listing)
|
|
58
|
+
@keybind_handler.set_terminal_ui(self)
|
|
59
|
+
|
|
60
|
+
@running = true
|
|
61
|
+
setup_terminal
|
|
62
|
+
|
|
63
|
+
begin
|
|
64
|
+
main_loop
|
|
65
|
+
ensure
|
|
66
|
+
cleanup_terminal
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def refresh_display
|
|
71
|
+
# ウィンドウサイズを更新してから画面をクリアして再描画
|
|
72
|
+
update_screen_size
|
|
73
|
+
print "\e[2J\e[H" # clear screen, cursor to home
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def setup_terminal
|
|
79
|
+
# terminal setup
|
|
80
|
+
system('tput smcup') # alternate screen
|
|
81
|
+
system('tput civis') # cursor invisible
|
|
82
|
+
print "\e[2J\e[H" # clear screen, cursor to home (first time only)
|
|
83
|
+
|
|
84
|
+
# re-acquire terminal size (just in case)
|
|
85
|
+
update_screen_size
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def update_screen_size
|
|
89
|
+
console = IO.console
|
|
90
|
+
return unless console
|
|
91
|
+
|
|
92
|
+
@screen_width, @screen_height = console.winsize.reverse
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def cleanup_terminal
|
|
96
|
+
system('tput rmcup') # normal screen
|
|
97
|
+
system('tput cnorm') # cursor normal
|
|
98
|
+
puts ConfigLoader.message('app.terminated')
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def main_loop
|
|
102
|
+
while @running
|
|
103
|
+
draw_screen
|
|
104
|
+
handle_input
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def draw_screen
|
|
109
|
+
# move cursor to top of screen (don't clear)
|
|
110
|
+
print "\e[H"
|
|
111
|
+
|
|
112
|
+
# header (2 lines)
|
|
113
|
+
draw_header
|
|
114
|
+
draw_base_directory_info
|
|
115
|
+
|
|
116
|
+
# main content (left: directory list, right: preview)
|
|
117
|
+
entries = get_display_entries
|
|
118
|
+
selected_entry = entries[@keybind_handler.current_index]
|
|
119
|
+
|
|
120
|
+
# calculate height with header and footer margin
|
|
121
|
+
content_height = @screen_height - HEADER_FOOTER_MARGIN
|
|
122
|
+
left_width = (@screen_width * LEFT_PANEL_RATIO).to_i
|
|
123
|
+
right_width = @screen_width - left_width
|
|
124
|
+
|
|
125
|
+
# adjust so right panel doesn't overflow into left panel
|
|
126
|
+
right_width = @screen_width - left_width if left_width + right_width > @screen_width
|
|
127
|
+
|
|
128
|
+
draw_directory_list(entries, left_width, content_height)
|
|
129
|
+
draw_file_preview(selected_entry, right_width, content_height, left_width)
|
|
130
|
+
|
|
131
|
+
# footer
|
|
132
|
+
draw_footer
|
|
133
|
+
|
|
134
|
+
# コマンドモードがアクティブな場合はコマンド入力ウィンドウを表示
|
|
135
|
+
if @command_mode_active
|
|
136
|
+
# 補完候補を取得
|
|
137
|
+
suggestions = @command_mode_ui.autocomplete(@command_input)
|
|
138
|
+
# フローティングウィンドウで表示
|
|
139
|
+
@command_mode_ui.show_input_prompt(@command_input, suggestions)
|
|
140
|
+
else
|
|
141
|
+
# move cursor to invisible position
|
|
142
|
+
print "\e[#{@screen_height};#{@screen_width}H"
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def draw_header
|
|
147
|
+
current_path = @directory_listing.current_path
|
|
148
|
+
header = "📁 rufio - #{current_path}"
|
|
149
|
+
|
|
150
|
+
# Add filter indicator if in filter mode
|
|
151
|
+
if @keybind_handler.filter_active?
|
|
152
|
+
filter_text = " [Filter: #{@keybind_handler.filter_query}]"
|
|
153
|
+
header += filter_text
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# abbreviate if path is too long
|
|
157
|
+
if header.length > @screen_width - HEADER_PADDING
|
|
158
|
+
if @keybind_handler.filter_active?
|
|
159
|
+
# prioritize showing filter when active
|
|
160
|
+
filter_text = " [Filter: #{@keybind_handler.filter_query}]"
|
|
161
|
+
base_length = @screen_width - filter_text.length - FILTER_TEXT_RESERVED
|
|
162
|
+
header = "📁 rufio - ...#{current_path[-base_length..-1]}#{filter_text}"
|
|
163
|
+
else
|
|
164
|
+
header = "📁 rufio - ...#{current_path[-(@screen_width - FILTER_TEXT_RESERVED)..-1]}"
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
puts "\e[7m#{header.ljust(@screen_width)}\e[0m" # reverse display
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def draw_base_directory_info
|
|
172
|
+
# 強制的に表示 - デバッグ用に安全チェックを緩和
|
|
173
|
+
if @keybind_handler && @keybind_handler.instance_variable_get(:@base_directory)
|
|
174
|
+
base_dir = @keybind_handler.instance_variable_get(:@base_directory)
|
|
175
|
+
selected_count = @keybind_handler.selected_items.length
|
|
176
|
+
base_info = "📋 Base Directory: #{base_dir}"
|
|
177
|
+
|
|
178
|
+
# 選択されたアイテム数を表示
|
|
179
|
+
if selected_count > 0
|
|
180
|
+
base_info += " | Selected: #{selected_count} item(s)"
|
|
181
|
+
end
|
|
182
|
+
else
|
|
183
|
+
# keybind_handlerがない場合、またはbase_directoryが設定されていない場合
|
|
184
|
+
base_info = "📋 Base Directory: #{Dir.pwd}"
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# 長すぎる場合は省略
|
|
188
|
+
if base_info.length > @screen_width - HEADER_PADDING
|
|
189
|
+
if base_info.include?(" | Selected:")
|
|
190
|
+
selected_part = base_info.split(" | Selected:").last
|
|
191
|
+
available_length = @screen_width - BASE_INFO_RESERVED_WIDTH - " | Selected:#{selected_part}".length
|
|
192
|
+
else
|
|
193
|
+
available_length = @screen_width - BASE_INFO_RESERVED_WIDTH
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
if available_length > BASE_INFO_MIN_WIDTH
|
|
197
|
+
# パスの最後の部分を表示
|
|
198
|
+
dir_part = base_info.split(": ").last.split(" | ").first
|
|
199
|
+
short_base_dir = "...#{dir_part[-available_length..-1]}"
|
|
200
|
+
base_info = base_info.gsub(dir_part, short_base_dir)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# 2行目に確実に表示
|
|
205
|
+
print "\e[2;1H\e[44m\e[37m#{base_info.ljust(@screen_width)}\e[0m"
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def draw_directory_list(entries, width, height)
|
|
210
|
+
start_index = [@keybind_handler.current_index - height / 2, 0].max
|
|
211
|
+
[start_index + height - 1, entries.length - 1].min
|
|
212
|
+
|
|
213
|
+
(0...height).each do |i|
|
|
214
|
+
entry_index = start_index + i
|
|
215
|
+
line_num = i + CONTENT_START_LINE
|
|
216
|
+
|
|
217
|
+
print "\e[#{line_num};1H" # set cursor position
|
|
218
|
+
|
|
219
|
+
if entry_index < entries.length
|
|
220
|
+
entry = entries[entry_index]
|
|
221
|
+
is_selected = entry_index == @keybind_handler.current_index
|
|
222
|
+
|
|
223
|
+
draw_entry_line(entry, width, is_selected)
|
|
224
|
+
else
|
|
225
|
+
# 左ペイン専用の安全な幅で空行を出力
|
|
226
|
+
safe_width = [width - CURSOR_OFFSET, (@screen_width * LEFT_PANEL_RATIO).to_i - CURSOR_OFFSET].min
|
|
227
|
+
print ' ' * safe_width
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def draw_entry_line(entry, width, is_selected)
|
|
233
|
+
# アイコンと色の設定
|
|
234
|
+
icon, color = get_entry_display_info(entry)
|
|
235
|
+
|
|
236
|
+
# 左ペイン専用の安全な幅を計算(右ペインにはみ出さないよう)
|
|
237
|
+
safe_width = [width - CURSOR_OFFSET, (@screen_width * LEFT_PANEL_RATIO).to_i - CURSOR_OFFSET].min
|
|
238
|
+
|
|
239
|
+
# 選択マークの追加
|
|
240
|
+
selection_mark = @keybind_handler.is_selected?(entry[:name]) ? "✓ " : " "
|
|
241
|
+
|
|
242
|
+
# ファイル名(必要に応じて切り詰め)
|
|
243
|
+
name = entry[:name]
|
|
244
|
+
max_name_length = safe_width - ICON_SIZE_PADDING
|
|
245
|
+
name = name[0...max_name_length - 3] + '...' if max_name_length > 0 && name.length > max_name_length
|
|
246
|
+
|
|
247
|
+
# サイズ情報
|
|
248
|
+
size_info = format_size(entry[:size])
|
|
249
|
+
|
|
250
|
+
# 行の内容を構築(安全な幅内で)
|
|
251
|
+
content_without_size = "#{selection_mark}#{icon} #{name}"
|
|
252
|
+
available_for_content = safe_width - size_info.length
|
|
253
|
+
|
|
254
|
+
line_content = if available_for_content > 0
|
|
255
|
+
content_without_size.ljust(available_for_content) + size_info
|
|
256
|
+
else
|
|
257
|
+
content_without_size
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# 確実に safe_width を超えないよう切り詰め
|
|
261
|
+
line_content = line_content[0...safe_width]
|
|
262
|
+
|
|
263
|
+
if is_selected
|
|
264
|
+
selected_color = ColorHelper.color_to_selected_ansi(ConfigLoader.colors[:selected])
|
|
265
|
+
print "#{selected_color}#{line_content}#{ColorHelper.reset}"
|
|
266
|
+
else
|
|
267
|
+
# 選択されたアイテムは異なる色で表示
|
|
268
|
+
if @keybind_handler.is_selected?(entry[:name])
|
|
269
|
+
print "\e[42m\e[30m#{line_content}\e[0m" # 緑背景、黒文字
|
|
270
|
+
else
|
|
271
|
+
print "#{color}#{line_content}#{ColorHelper.reset}"
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def get_entry_display_info(entry)
|
|
277
|
+
colors = ConfigLoader.colors
|
|
278
|
+
|
|
279
|
+
case entry[:type]
|
|
280
|
+
when 'directory'
|
|
281
|
+
color_code = ColorHelper.color_to_ansi(colors[:directory])
|
|
282
|
+
['📁', color_code]
|
|
283
|
+
when 'executable'
|
|
284
|
+
color_code = ColorHelper.color_to_ansi(colors[:executable])
|
|
285
|
+
['⚡', color_code]
|
|
286
|
+
else
|
|
287
|
+
case File.extname(entry[:name]).downcase
|
|
288
|
+
when '.rb'
|
|
289
|
+
['💎', "\e[31m"] # 赤
|
|
290
|
+
when '.js', '.ts'
|
|
291
|
+
['📜', "\e[33m"] # 黄
|
|
292
|
+
when '.txt', '.md'
|
|
293
|
+
color_code = ColorHelper.color_to_ansi(colors[:file])
|
|
294
|
+
['📄', color_code]
|
|
295
|
+
else
|
|
296
|
+
color_code = ColorHelper.color_to_ansi(colors[:file])
|
|
297
|
+
['📄', color_code]
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def format_size(size)
|
|
303
|
+
return ' ' if size == 0
|
|
304
|
+
|
|
305
|
+
if size < KILOBYTE
|
|
306
|
+
"#{size}B".rjust(6)
|
|
307
|
+
elsif size < MEGABYTE
|
|
308
|
+
"#{(size / KILOBYTE.to_f).round(1)}K".rjust(6)
|
|
309
|
+
elsif size < GIGABYTE
|
|
310
|
+
"#{(size / MEGABYTE.to_f).round(1)}M".rjust(6)
|
|
311
|
+
else
|
|
312
|
+
"#{(size / GIGABYTE.to_f).round(1)}G".rjust(6)
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def draw_file_preview(selected_entry, width, height, left_offset)
|
|
317
|
+
(0...height).each do |i|
|
|
318
|
+
line_num = i + CONTENT_START_LINE
|
|
319
|
+
# カーソル位置を左パネルの右端に設定
|
|
320
|
+
cursor_position = left_offset + CURSOR_OFFSET
|
|
321
|
+
|
|
322
|
+
# 画面の境界を厳密に計算
|
|
323
|
+
max_chars_from_cursor = @screen_width - cursor_position
|
|
324
|
+
# 区切り線(│)分を除いて、さらに安全マージンを取る
|
|
325
|
+
safe_width = [max_chars_from_cursor - 2, width - 2, 0].max
|
|
326
|
+
|
|
327
|
+
print "\e[#{line_num};#{cursor_position}H" # カーソル位置設定
|
|
328
|
+
print '│' # 区切り線
|
|
329
|
+
|
|
330
|
+
content_to_print = ''
|
|
331
|
+
|
|
332
|
+
if selected_entry && i == 0
|
|
333
|
+
# プレビューヘッダー
|
|
334
|
+
header = " #{selected_entry[:name]} "
|
|
335
|
+
content_to_print = header
|
|
336
|
+
elsif selected_entry && selected_entry[:type] == 'file' && i >= 2
|
|
337
|
+
# ファイルプレビュー(折り返し対応)
|
|
338
|
+
preview_content = get_preview_content(selected_entry)
|
|
339
|
+
wrapped_lines = wrap_preview_lines(preview_content, safe_width - 1) # スペース分を除く
|
|
340
|
+
display_line_index = i - 2
|
|
341
|
+
|
|
342
|
+
if display_line_index < wrapped_lines.length
|
|
343
|
+
line = wrapped_lines[display_line_index] || ''
|
|
344
|
+
# スペースを先頭に追加
|
|
345
|
+
content_to_print = " #{line}"
|
|
346
|
+
else
|
|
347
|
+
content_to_print = ' '
|
|
348
|
+
end
|
|
349
|
+
else
|
|
350
|
+
content_to_print = ' '
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# 絶対にsafe_widthを超えないよう強制的に切り詰める
|
|
354
|
+
if safe_width <= 0
|
|
355
|
+
# 表示スペースがない場合は何も出力しない
|
|
356
|
+
next
|
|
357
|
+
elsif display_width(content_to_print) > safe_width
|
|
358
|
+
# 表示幅ベースで切り詰める
|
|
359
|
+
content_to_print = truncate_to_width(content_to_print, safe_width)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# 出力(パディングなし、はみ出し防止のため)
|
|
363
|
+
print content_to_print
|
|
364
|
+
|
|
365
|
+
# 残りのスペースを埋める(ただし安全な範囲内のみ)
|
|
366
|
+
remaining_space = safe_width - display_width(content_to_print)
|
|
367
|
+
print ' ' * remaining_space if remaining_space > 0
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def get_preview_content(entry)
|
|
372
|
+
return [] unless entry && entry[:type] == 'file'
|
|
373
|
+
|
|
374
|
+
preview = @file_preview.preview_file(entry[:path])
|
|
375
|
+
case preview[:type]
|
|
376
|
+
when 'text', 'code'
|
|
377
|
+
preview[:lines]
|
|
378
|
+
when 'binary'
|
|
379
|
+
["(#{ConfigLoader.message('file.binary_file')})", ConfigLoader.message('file.cannot_preview')]
|
|
380
|
+
when 'error'
|
|
381
|
+
["#{ConfigLoader.message('file.error_prefix')}:", preview[:message]]
|
|
382
|
+
else
|
|
383
|
+
["(#{ConfigLoader.message('file.cannot_preview')})"]
|
|
384
|
+
end
|
|
385
|
+
rescue StandardError
|
|
386
|
+
["(#{ConfigLoader.message('file.preview_error')})"]
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def wrap_preview_lines(lines, max_width)
|
|
390
|
+
return [] if lines.empty? || max_width <= 0
|
|
391
|
+
|
|
392
|
+
wrapped_lines = []
|
|
393
|
+
|
|
394
|
+
lines.each do |line|
|
|
395
|
+
if display_width(line) <= max_width
|
|
396
|
+
# 短い行はそのまま追加
|
|
397
|
+
wrapped_lines << line
|
|
398
|
+
else
|
|
399
|
+
# 長い行は折り返し
|
|
400
|
+
remaining_line = line
|
|
401
|
+
while display_width(remaining_line) > max_width
|
|
402
|
+
# 単語境界で折り返すことを試みる
|
|
403
|
+
break_point = find_break_point(remaining_line, max_width)
|
|
404
|
+
wrapped_lines << remaining_line[0...break_point]
|
|
405
|
+
remaining_line = remaining_line[break_point..-1]
|
|
406
|
+
end
|
|
407
|
+
# 残りの部分を追加
|
|
408
|
+
wrapped_lines << remaining_line if remaining_line.length > 0
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
wrapped_lines
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def display_width(string)
|
|
416
|
+
# 文字列の表示幅を計算する
|
|
417
|
+
# 日本語文字(全角)は幅2、ASCII文字(半角)は幅1として計算
|
|
418
|
+
width = 0
|
|
419
|
+
string.each_char do |char|
|
|
420
|
+
# 全角文字の判定
|
|
421
|
+
width += if char.ord > 127 || char.match?(/[あ-ん ア-ン 一-龯]/)
|
|
422
|
+
2
|
|
423
|
+
else
|
|
424
|
+
1
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
width
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def truncate_to_width(string, max_width)
|
|
431
|
+
# 表示幅を指定して文字列を切り詰める
|
|
432
|
+
return string if display_width(string) <= max_width
|
|
433
|
+
|
|
434
|
+
current_width = 0
|
|
435
|
+
result = ''
|
|
436
|
+
|
|
437
|
+
string.each_char do |char|
|
|
438
|
+
char_width = char.ord > 127 || char.match?(/[あ-ん ア-ン 一-龯]/) ? 2 : 1
|
|
439
|
+
|
|
440
|
+
if current_width + char_width > max_width
|
|
441
|
+
# "..."を追加できるかチェック
|
|
442
|
+
result += '...' if max_width >= 3 && current_width <= max_width - 3
|
|
443
|
+
break
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
result += char
|
|
447
|
+
current_width += char_width
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
result
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def find_break_point(line, max_width)
|
|
454
|
+
# 最大幅以内で適切な折り返し位置を見つける
|
|
455
|
+
return line.length if display_width(line) <= max_width
|
|
456
|
+
|
|
457
|
+
# 文字ごとに幅を計算しながら適切な位置を探す
|
|
458
|
+
current_width = 0
|
|
459
|
+
best_break_point = 0
|
|
460
|
+
space_break_point = nil
|
|
461
|
+
punct_break_point = nil
|
|
462
|
+
|
|
463
|
+
line.each_char.with_index do |char, index|
|
|
464
|
+
char_width = char.ord > 127 || char.match?(/[あ-ん ア-ン 一-龯]/) ? 2 : 1
|
|
465
|
+
|
|
466
|
+
break if current_width + char_width > max_width
|
|
467
|
+
|
|
468
|
+
current_width += char_width
|
|
469
|
+
best_break_point = index + 1
|
|
470
|
+
|
|
471
|
+
# スペースで区切れる位置を記録
|
|
472
|
+
space_break_point = index + 1 if char == ' ' && current_width > max_width * 0.5
|
|
473
|
+
|
|
474
|
+
# 日本語の句読点で区切れる位置を記録
|
|
475
|
+
punct_break_point = index + 1 if char.match?(/[、。,.!?]/) && current_width > max_width * 0.5
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
# 最適な折り返し位置を選択
|
|
479
|
+
space_break_point || punct_break_point || best_break_point
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
def get_display_entries
|
|
483
|
+
if @keybind_handler.filter_active?
|
|
484
|
+
# Get filtered entries from keybind_handler
|
|
485
|
+
all_entries = @directory_listing.list_entries
|
|
486
|
+
query = @keybind_handler.filter_query.downcase
|
|
487
|
+
query.empty? ? all_entries : all_entries.select { |entry| entry[:name].downcase.include?(query) }
|
|
488
|
+
else
|
|
489
|
+
@directory_listing.list_entries
|
|
490
|
+
end
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
def draw_footer
|
|
494
|
+
# 最下行から1行上に表示してスクロールを避ける
|
|
495
|
+
footer_line = @screen_height - FOOTER_HEIGHT
|
|
496
|
+
print "\e[#{footer_line};1H"
|
|
497
|
+
|
|
498
|
+
if @keybind_handler.filter_active?
|
|
499
|
+
if @keybind_handler.instance_variable_get(:@filter_mode)
|
|
500
|
+
help_text = "Filter mode: Type to filter, ESC to clear, Enter to apply, Backspace to delete"
|
|
501
|
+
else
|
|
502
|
+
help_text = "Filtered view active - Space to edit filter, ESC to clear filter"
|
|
503
|
+
end
|
|
504
|
+
else
|
|
505
|
+
help_text = ConfigLoader.message('help.full')
|
|
506
|
+
help_text = ConfigLoader.message('help.short') if help_text.length > @screen_width
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
# 文字列を確実に画面幅に合わせる
|
|
510
|
+
footer_content = help_text.ljust(@screen_width)[0...@screen_width]
|
|
511
|
+
print "\e[7m#{footer_content}\e[0m"
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def handle_input
|
|
515
|
+
begin
|
|
516
|
+
input = STDIN.getch
|
|
517
|
+
rescue Errno::ENOTTY, Errno::ENODEV
|
|
518
|
+
# ターミナルでない環境(IDE等)では標準入力を使用
|
|
519
|
+
print "\nOperation: "
|
|
520
|
+
input = STDIN.gets
|
|
521
|
+
return 'q' if input.nil?
|
|
522
|
+
input = input.chomp.downcase
|
|
523
|
+
return input[0] if input.length > 0
|
|
524
|
+
|
|
525
|
+
return 'q'
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
# 特殊キーの処理
|
|
529
|
+
if input == "\e"
|
|
530
|
+
# エスケープシーケンスの処理
|
|
531
|
+
next_char = begin
|
|
532
|
+
STDIN.read_nonblock(1)
|
|
533
|
+
rescue StandardError
|
|
534
|
+
nil
|
|
535
|
+
end
|
|
536
|
+
if next_char == '['
|
|
537
|
+
arrow_key = begin
|
|
538
|
+
STDIN.read_nonblock(1)
|
|
539
|
+
rescue StandardError
|
|
540
|
+
nil
|
|
541
|
+
end
|
|
542
|
+
input = case arrow_key
|
|
543
|
+
when 'A' # 上矢印
|
|
544
|
+
'k'
|
|
545
|
+
when 'B' # 下矢印
|
|
546
|
+
'j'
|
|
547
|
+
when 'C' # 右矢印
|
|
548
|
+
'l'
|
|
549
|
+
when 'D' # 左矢印
|
|
550
|
+
'h'
|
|
551
|
+
else
|
|
552
|
+
"\e" # ESCキー(そのまま保持)
|
|
553
|
+
end
|
|
554
|
+
else
|
|
555
|
+
input = "\e" # ESCキー(そのまま保持)
|
|
556
|
+
end
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
# コマンドモードがアクティブな場合は、コマンド入力を処理
|
|
560
|
+
if @command_mode_active
|
|
561
|
+
handle_command_input(input)
|
|
562
|
+
return
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
# キーバインドハンドラーに処理を委譲
|
|
566
|
+
result = @keybind_handler.handle_key(input)
|
|
567
|
+
|
|
568
|
+
# 終了処理(qキーのみ)
|
|
569
|
+
if input == 'q'
|
|
570
|
+
@running = false
|
|
571
|
+
end
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
# コマンドモード関連のメソッドは public にする
|
|
575
|
+
public
|
|
576
|
+
|
|
577
|
+
# コマンドモードを起動
|
|
578
|
+
def activate_command_mode
|
|
579
|
+
@command_mode_active = true
|
|
580
|
+
@command_input = ""
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
# コマンドモードを終了
|
|
584
|
+
def deactivate_command_mode
|
|
585
|
+
@command_mode_active = false
|
|
586
|
+
@command_input = ""
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
# コマンドモードがアクティブかどうか
|
|
590
|
+
def command_mode_active?
|
|
591
|
+
@command_mode_active
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
# コマンド入力を処理
|
|
595
|
+
def handle_command_input(input)
|
|
596
|
+
case input
|
|
597
|
+
when "\r", "\n"
|
|
598
|
+
# Enter キーでコマンドを実行
|
|
599
|
+
execute_command(@command_input)
|
|
600
|
+
deactivate_command_mode
|
|
601
|
+
when "\e"
|
|
602
|
+
# Escape キーでコマンドモードをキャンセル
|
|
603
|
+
deactivate_command_mode
|
|
604
|
+
when "\t"
|
|
605
|
+
# Tab キーで補完
|
|
606
|
+
@command_input = @command_mode_ui.complete_command(@command_input)
|
|
607
|
+
when "\u007F", "\b"
|
|
608
|
+
# Backspace
|
|
609
|
+
@command_input.chop! unless @command_input.empty?
|
|
610
|
+
else
|
|
611
|
+
# 通常の文字を追加
|
|
612
|
+
@command_input += input if input.length == 1
|
|
613
|
+
end
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
# コマンドを実行
|
|
617
|
+
def execute_command(command_string)
|
|
618
|
+
return if command_string.nil? || command_string.empty?
|
|
619
|
+
|
|
620
|
+
result = @command_mode.execute(command_string)
|
|
621
|
+
|
|
622
|
+
# コマンド実行結果をフローティングウィンドウで表示
|
|
623
|
+
@command_mode_ui.show_result(result) if result
|
|
624
|
+
|
|
625
|
+
# 画面を再描画
|
|
626
|
+
draw_screen
|
|
627
|
+
end
|
|
628
|
+
end
|
|
629
|
+
end
|
|
630
|
+
|