beniya 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,469 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'io/console'
4
+
5
+ module Beniya
6
+ class TerminalUI
7
+ def initialize
8
+ console = IO.console
9
+ if console
10
+ @screen_width, @screen_height = console.winsize.reverse
11
+ else
12
+ # fallback values (for test environments etc.)
13
+ @screen_width = 80
14
+ @screen_height = 24
15
+ end
16
+ @running = false
17
+ end
18
+
19
+ def start(directory_listing, keybind_handler, file_preview)
20
+ @directory_listing = directory_listing
21
+ @keybind_handler = keybind_handler
22
+ @file_preview = file_preview
23
+ @keybind_handler.set_directory_listing(@directory_listing)
24
+
25
+ @running = true
26
+ setup_terminal
27
+
28
+ begin
29
+ main_loop
30
+ ensure
31
+ cleanup_terminal
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def setup_terminal
38
+ # terminal setup
39
+ system('tput smcup') # alternate screen
40
+ system('tput civis') # cursor invisible
41
+ print "\e[2J\e[H" # clear screen, cursor to home (first time only)
42
+
43
+ # re-acquire terminal size (just in case)
44
+ console = IO.console
45
+ return unless console
46
+
47
+ @screen_width, @screen_height = console.winsize.reverse
48
+ end
49
+
50
+ def cleanup_terminal
51
+ system('tput rmcup') # normal screen
52
+ system('tput cnorm') # cursor normal
53
+ puts ConfigLoader.message('app.terminated')
54
+ end
55
+
56
+ def main_loop
57
+ while @running
58
+ draw_screen
59
+ handle_input
60
+ end
61
+ end
62
+
63
+ def draw_screen
64
+ # move cursor to top of screen (don't clear)
65
+ print "\e[H"
66
+
67
+ # header
68
+ draw_header
69
+
70
+ # main content (left: directory list, right: preview)
71
+ entries = get_display_entries
72
+ selected_entry = entries[@keybind_handler.current_index]
73
+
74
+ # calculate height with footer margin
75
+ content_height = @screen_height - 3 # ヘッダーとフッター分を除く
76
+ left_width = @screen_width / 2
77
+ right_width = @screen_width - left_width
78
+
79
+ # adjust so right panel doesn't overflow into left panel
80
+ right_width = @screen_width - left_width if left_width + right_width > @screen_width
81
+
82
+ draw_directory_list(entries, left_width, content_height)
83
+ draw_file_preview(selected_entry, right_width, content_height, left_width)
84
+
85
+ # footer
86
+ draw_footer
87
+
88
+ # move cursor to invisible position
89
+ print "\e[#{@screen_height};#{@screen_width}H"
90
+ end
91
+
92
+ def draw_header
93
+ current_path = @directory_listing.current_path
94
+ header = "📁 beniya - #{current_path}"
95
+
96
+ # Add filter indicator if in filter mode
97
+ if @keybind_handler.filter_active?
98
+ filter_text = " [Filter: #{@keybind_handler.filter_query}]"
99
+ header += filter_text
100
+ end
101
+
102
+ # abbreviate if path is too long
103
+ if header.length > @screen_width - 2
104
+ if @keybind_handler.filter_active?
105
+ # prioritize showing filter when active
106
+ filter_text = " [Filter: #{@keybind_handler.filter_query}]"
107
+ base_length = @screen_width - filter_text.length - 15
108
+ header = "📁 beniya - ...#{current_path[-base_length..-1]}#{filter_text}"
109
+ else
110
+ header = "📁 beniya - ...#{current_path[-(@screen_width - 15)..-1]}"
111
+ end
112
+ end
113
+
114
+ puts "\e[7m#{header.ljust(@screen_width)}\e[0m" # reverse display
115
+ end
116
+
117
+ def draw_directory_list(entries, width, height)
118
+ start_index = [@keybind_handler.current_index - height / 2, 0].max
119
+ [start_index + height - 1, entries.length - 1].min
120
+
121
+ (0...height).each do |i|
122
+ entry_index = start_index + i
123
+ line_num = i + 2 # skip header
124
+
125
+ print "\e[#{line_num};1H" # set cursor position
126
+
127
+ if entry_index < entries.length
128
+ entry = entries[entry_index]
129
+ is_selected = entry_index == @keybind_handler.current_index
130
+
131
+ draw_entry_line(entry, width, is_selected)
132
+ else
133
+ # 左ペイン専用の安全な幅で空行を出力
134
+ safe_width = [width - 1, @screen_width / 2 - 1].min
135
+ print ' ' * safe_width
136
+ end
137
+ end
138
+ end
139
+
140
+ def draw_entry_line(entry, width, is_selected)
141
+ # アイコンと色の設定
142
+ icon, color = get_entry_display_info(entry)
143
+
144
+ # 左ペイン専用の安全な幅を計算(右ペインにはみ出さないよう)
145
+ safe_width = [width - 1, @screen_width / 2 - 1].min
146
+
147
+ # ファイル名(必要に応じて切り詰め)
148
+ name = entry[:name]
149
+ max_name_length = safe_width - 10 # アイコンとサイズ情報分を除く
150
+ name = name[0...max_name_length - 3] + '...' if max_name_length > 0 && name.length > max_name_length
151
+
152
+ # サイズ情報
153
+ size_info = format_size(entry[:size])
154
+
155
+ # 行の内容を構築(安全な幅内で)
156
+ content_without_size = "#{icon} #{name}"
157
+ available_for_content = safe_width - size_info.length
158
+
159
+ line_content = if available_for_content > 0
160
+ content_without_size.ljust(available_for_content) + size_info
161
+ else
162
+ content_without_size
163
+ end
164
+
165
+ # 確実に safe_width を超えないよう切り詰め
166
+ line_content = line_content[0...safe_width]
167
+
168
+ if is_selected
169
+ selected_color = ColorHelper.color_to_selected_ansi(ConfigLoader.colors[:selected])
170
+ print "#{selected_color}#{line_content}#{ColorHelper.reset}"
171
+ else
172
+ print "#{color}#{line_content}#{ColorHelper.reset}"
173
+ end
174
+ end
175
+
176
+ def get_entry_display_info(entry)
177
+ colors = ConfigLoader.colors
178
+
179
+ case entry[:type]
180
+ when 'directory'
181
+ color_code = ColorHelper.color_to_ansi(colors[:directory])
182
+ ['📁', color_code]
183
+ when 'executable'
184
+ color_code = ColorHelper.color_to_ansi(colors[:executable])
185
+ ['⚡', color_code]
186
+ else
187
+ case File.extname(entry[:name]).downcase
188
+ when '.rb'
189
+ ['💎', "\e[31m"] # 赤
190
+ when '.js', '.ts'
191
+ ['📜', "\e[33m"] # 黄
192
+ when '.txt', '.md'
193
+ color_code = ColorHelper.color_to_ansi(colors[:file])
194
+ ['📄', color_code]
195
+ else
196
+ color_code = ColorHelper.color_to_ansi(colors[:file])
197
+ ['📄', color_code]
198
+ end
199
+ end
200
+ end
201
+
202
+ def format_size(size)
203
+ return ' ' if size == 0
204
+
205
+ if size < 1024
206
+ "#{size}B".rjust(6)
207
+ elsif size < 1024 * 1024
208
+ "#{(size / 1024.0).round(1)}K".rjust(6)
209
+ elsif size < 1024 * 1024 * 1024
210
+ "#{(size / (1024.0 * 1024)).round(1)}M".rjust(6)
211
+ else
212
+ "#{(size / (1024.0 * 1024 * 1024)).round(1)}G".rjust(6)
213
+ end
214
+ end
215
+
216
+ def draw_file_preview(selected_entry, width, height, left_offset)
217
+ (0...height).each do |i|
218
+ line_num = i + 2
219
+ # カーソル位置を左パネルの右端に設定
220
+ cursor_position = left_offset + 1
221
+
222
+ # 画面の境界を厳密に計算
223
+ max_chars_from_cursor = @screen_width - cursor_position
224
+ # 区切り線(│)分を除いて、さらに安全マージンを取る
225
+ safe_width = [max_chars_from_cursor - 2, width - 2, 0].max
226
+
227
+ print "\e[#{line_num};#{cursor_position}H" # カーソル位置設定
228
+ print '│' # 区切り線
229
+
230
+ content_to_print = ''
231
+
232
+ if selected_entry && i == 0
233
+ # プレビューヘッダー
234
+ header = " #{selected_entry[:name]} "
235
+ content_to_print = header
236
+ elsif selected_entry && selected_entry[:type] == 'file' && i >= 2
237
+ # ファイルプレビュー(折り返し対応)
238
+ preview_content = get_preview_content(selected_entry)
239
+ wrapped_lines = wrap_preview_lines(preview_content, safe_width - 1) # スペース分を除く
240
+ display_line_index = i - 2
241
+
242
+ if display_line_index < wrapped_lines.length
243
+ line = wrapped_lines[display_line_index] || ''
244
+ # スペースを先頭に追加
245
+ content_to_print = " #{line}"
246
+ else
247
+ content_to_print = ' '
248
+ end
249
+ else
250
+ content_to_print = ' '
251
+ end
252
+
253
+ # 絶対にsafe_widthを超えないよう強制的に切り詰める
254
+ if safe_width <= 0
255
+ # 表示スペースがない場合は何も出力しない
256
+ next
257
+ elsif display_width(content_to_print) > safe_width
258
+ # 表示幅ベースで切り詰める
259
+ content_to_print = truncate_to_width(content_to_print, safe_width)
260
+ end
261
+
262
+ # 出力(パディングなし、はみ出し防止のため)
263
+ print content_to_print
264
+
265
+ # 残りのスペースを埋める(ただし安全な範囲内のみ)
266
+ remaining_space = safe_width - display_width(content_to_print)
267
+ print ' ' * remaining_space if remaining_space > 0
268
+ end
269
+ end
270
+
271
+ def get_preview_content(entry)
272
+ return [] unless entry && entry[:type] == 'file'
273
+
274
+ preview = @file_preview.preview_file(entry[:path])
275
+ case preview[:type]
276
+ when 'text', 'code'
277
+ preview[:lines]
278
+ when 'binary'
279
+ ["(#{ConfigLoader.message('file.binary_file')})", ConfigLoader.message('file.cannot_preview')]
280
+ when 'error'
281
+ ["#{ConfigLoader.message('file.error_prefix')}:", preview[:message]]
282
+ else
283
+ ["(#{ConfigLoader.message('file.cannot_preview')})"]
284
+ end
285
+ rescue StandardError
286
+ ["(#{ConfigLoader.message('file.preview_error')})"]
287
+ end
288
+
289
+ def wrap_preview_lines(lines, max_width)
290
+ return [] if lines.empty? || max_width <= 0
291
+
292
+ wrapped_lines = []
293
+
294
+ lines.each do |line|
295
+ if display_width(line) <= max_width
296
+ # 短い行はそのまま追加
297
+ wrapped_lines << line
298
+ else
299
+ # 長い行は折り返し
300
+ remaining_line = line
301
+ while display_width(remaining_line) > max_width
302
+ # 単語境界で折り返すことを試みる
303
+ break_point = find_break_point(remaining_line, max_width)
304
+ wrapped_lines << remaining_line[0...break_point]
305
+ remaining_line = remaining_line[break_point..-1]
306
+ end
307
+ # 残りの部分を追加
308
+ wrapped_lines << remaining_line if remaining_line.length > 0
309
+ end
310
+ end
311
+
312
+ wrapped_lines
313
+ end
314
+
315
+ def display_width(string)
316
+ # 文字列の表示幅を計算する
317
+ # 日本語文字(全角)は幅2、ASCII文字(半角)は幅1として計算
318
+ width = 0
319
+ string.each_char do |char|
320
+ # 全角文字の判定
321
+ width += if char.ord > 127 || char.match?(/[あ-ん ア-ン 一-龯]/)
322
+ 2
323
+ else
324
+ 1
325
+ end
326
+ end
327
+ width
328
+ end
329
+
330
+ def truncate_to_width(string, max_width)
331
+ # 表示幅を指定して文字列を切り詰める
332
+ return string if display_width(string) <= max_width
333
+
334
+ current_width = 0
335
+ result = ''
336
+
337
+ string.each_char do |char|
338
+ char_width = char.ord > 127 || char.match?(/[あ-ん ア-ン 一-龯]/) ? 2 : 1
339
+
340
+ if current_width + char_width > max_width
341
+ # "..."を追加できるかチェック
342
+ result += '...' if max_width >= 3 && current_width <= max_width - 3
343
+ break
344
+ end
345
+
346
+ result += char
347
+ current_width += char_width
348
+ end
349
+
350
+ result
351
+ end
352
+
353
+ def find_break_point(line, max_width)
354
+ # 最大幅以内で適切な折り返し位置を見つける
355
+ return line.length if display_width(line) <= max_width
356
+
357
+ # 文字ごとに幅を計算しながら適切な位置を探す
358
+ current_width = 0
359
+ best_break_point = 0
360
+ space_break_point = nil
361
+ punct_break_point = nil
362
+
363
+ line.each_char.with_index do |char, index|
364
+ char_width = char.ord > 127 || char.match?(/[あ-ん ア-ン 一-龯]/) ? 2 : 1
365
+
366
+ break if current_width + char_width > max_width
367
+
368
+ current_width += char_width
369
+ best_break_point = index + 1
370
+
371
+ # スペースで区切れる位置を記録
372
+ space_break_point = index + 1 if char == ' ' && current_width > max_width * 0.5
373
+
374
+ # 日本語の句読点で区切れる位置を記録
375
+ punct_break_point = index + 1 if char.match?(/[、。,.!?]/) && current_width > max_width * 0.5
376
+ end
377
+
378
+ # 最適な折り返し位置を選択
379
+ space_break_point || punct_break_point || best_break_point
380
+ end
381
+
382
+ def get_display_entries
383
+ if @keybind_handler.filter_active?
384
+ # Get filtered entries from keybind_handler
385
+ all_entries = @directory_listing.list_entries
386
+ query = @keybind_handler.filter_query.downcase
387
+ query.empty? ? all_entries : all_entries.select { |entry| entry[:name].downcase.include?(query) }
388
+ else
389
+ @directory_listing.list_entries
390
+ end
391
+ end
392
+
393
+ def draw_footer
394
+ # 最下行から1行上に表示してスクロールを避ける
395
+ footer_line = @screen_height - 1
396
+ print "\e[#{footer_line};1H"
397
+
398
+ if @keybind_handler.filter_active?
399
+ if @keybind_handler.instance_variable_get(:@filter_mode)
400
+ help_text = "Filter mode: Type to filter, ESC to clear, Enter to apply, Backspace to delete"
401
+ else
402
+ help_text = "Filtered view active - Space to edit filter, ESC to clear filter"
403
+ end
404
+ else
405
+ help_text = ConfigLoader.message('help.full')
406
+ help_text = ConfigLoader.message('help.short') if help_text.length > @screen_width
407
+ end
408
+
409
+ # 文字列を確実に画面幅に合わせる
410
+ footer_content = help_text.ljust(@screen_width)[0...@screen_width]
411
+ print "\e[7m#{footer_content}\e[0m"
412
+ end
413
+
414
+ def handle_input
415
+ begin
416
+ input = STDIN.getch
417
+ rescue Errno::ENOTTY, Errno::ENODEV
418
+ # ターミナルでない環境(IDE等)では標準入力を使用
419
+ print "\n操作: "
420
+ input = STDIN.gets
421
+ return 'q' if input.nil?
422
+ input = input.chomp.downcase
423
+ return input[0] if input.length > 0
424
+
425
+ return 'q'
426
+ end
427
+
428
+ # 特殊キーの処理
429
+ if input == "\e"
430
+ # エスケープシーケンスの処理
431
+ next_char = begin
432
+ STDIN.read_nonblock(1)
433
+ rescue StandardError
434
+ nil
435
+ end
436
+ if next_char == '['
437
+ arrow_key = begin
438
+ STDIN.read_nonblock(1)
439
+ rescue StandardError
440
+ nil
441
+ end
442
+ input = case arrow_key
443
+ when 'A' # 上矢印
444
+ 'k'
445
+ when 'B' # 下矢印
446
+ 'j'
447
+ when 'C' # 右矢印
448
+ 'l'
449
+ when 'D' # 左矢印
450
+ 'h'
451
+ else
452
+ "\e" # ESCキー(そのまま保持)
453
+ end
454
+ else
455
+ input = "\e" # ESCキー(そのまま保持)
456
+ end
457
+ end
458
+
459
+ # キーバインドハンドラーに処理を委譲
460
+ result = @keybind_handler.handle_key(input)
461
+
462
+ # 終了処理(qキーのみ)
463
+ if input == 'q'
464
+ @running = false
465
+ end
466
+ end
467
+ end
468
+ end
469
+
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Beniya
4
+ VERSION = "0.1.0"
5
+ end
data/lib/beniya.rb ADDED
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "beniya/version"
4
+ require_relative "beniya/config"
5
+ require_relative "beniya/config_loader"
6
+ require_relative "beniya/color_helper"
7
+ require_relative "beniya/directory_listing"
8
+ require_relative "beniya/keybind_handler"
9
+ require_relative "beniya/file_preview"
10
+ require_relative "beniya/terminal_ui"
11
+ require_relative "beniya/application"
12
+ require_relative "beniya/file_opener"
13
+ require_relative "beniya/health_checker"
14
+
15
+ module Beniya
16
+ class Error < StandardError; end
17
+ end
metadata ADDED
@@ -0,0 +1,161 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: beniya
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - beniya
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2025-08-17 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: io-console
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.6'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.6'
26
+ - !ruby/object:Gem::Dependency
27
+ name: pastel
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.8'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.8'
40
+ - !ruby/object:Gem::Dependency
41
+ name: tty-cursor
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '0.7'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '0.7'
54
+ - !ruby/object:Gem::Dependency
55
+ name: tty-screen
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '0.8'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '0.8'
68
+ - !ruby/object:Gem::Dependency
69
+ name: minitest
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '5.0'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '5.0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: rake
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '13.0'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '13.0'
96
+ - !ruby/object:Gem::Dependency
97
+ name: rubocop
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '1.21'
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '1.21'
110
+ description: A terminal-based file manager inspired by Yazi, written in Ruby with
111
+ plugin support
112
+ email:
113
+ - masisz.1567@gmail.com
114
+ executables:
115
+ - beniya
116
+ extensions: []
117
+ extra_rdoc_files: []
118
+ files:
119
+ - README.md
120
+ - README_EN.md
121
+ - beniya.gemspec
122
+ - bin/beniya
123
+ - config_example.rb
124
+ - lib/beniya.rb
125
+ - lib/beniya/application.rb
126
+ - lib/beniya/color_helper.rb
127
+ - lib/beniya/config.rb
128
+ - lib/beniya/config_loader.rb
129
+ - lib/beniya/directory_listing.rb
130
+ - lib/beniya/file_opener.rb
131
+ - lib/beniya/file_preview.rb
132
+ - lib/beniya/health_checker.rb
133
+ - lib/beniya/keybind_handler.rb
134
+ - lib/beniya/terminal_ui.rb
135
+ - lib/beniya/version.rb
136
+ homepage: https://github.com/masisz/beniya
137
+ licenses:
138
+ - MIT
139
+ metadata:
140
+ allowed_push_host: https://rubygems.org
141
+ homepage_uri: https://github.com/masisz/beniya
142
+ source_code_uri: https://github.com/masisz/beniya
143
+ changelog_uri: https://github.com/masisz/beniya/blob/main/CHANGELOG.md
144
+ rdoc_options: []
145
+ require_paths:
146
+ - lib
147
+ required_ruby_version: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - ">="
150
+ - !ruby/object:Gem::Version
151
+ version: 2.7.0
152
+ required_rubygems_version: !ruby/object:Gem::Requirement
153
+ requirements:
154
+ - - ">="
155
+ - !ruby/object:Gem::Version
156
+ version: '0'
157
+ requirements: []
158
+ rubygems_version: 3.6.6
159
+ specification_version: 4
160
+ summary: Ruby file manager
161
+ test_files: []