rufio 0.34.0 → 0.40.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 +54 -0
- data/bin/rufio +17 -2
- data/docs/{CHANGELOG_v0.34.0.md → CHANGELOG_v0.33.0.md} +2 -2
- data/docs/CHANGELOG_v0.40.0.md +416 -0
- data/lib/rufio/application.rb +2 -2
- data/lib/rufio/color_helper.rb +59 -6
- data/lib/rufio/command_mode_ui.rb +18 -0
- data/lib/rufio/dialog_renderer.rb +68 -0
- data/lib/rufio/keybind_handler.rb +53 -2
- data/lib/rufio/plugins/stop.rb +32 -0
- data/lib/rufio/renderer.rb +64 -0
- data/lib/rufio/screen.rb +184 -0
- data/lib/rufio/terminal_ui.rb +557 -34
- data/lib/rufio/text_utils.rb +30 -18
- data/lib/rufio/version.rb +1 -1
- data/lib/rufio.rb +2 -0
- metadata +7 -3
|
@@ -8,6 +8,8 @@ module Rufio
|
|
|
8
8
|
def initialize(command_mode, dialog_renderer)
|
|
9
9
|
@command_mode = command_mode
|
|
10
10
|
@dialog_renderer = dialog_renderer
|
|
11
|
+
# 最後に表示したウィンドウの位置とサイズを保存
|
|
12
|
+
@last_window = nil
|
|
11
13
|
end
|
|
12
14
|
|
|
13
15
|
# 入力文字列に対する補完候補を取得
|
|
@@ -68,6 +70,9 @@ module Rufio
|
|
|
68
70
|
# 中央位置を計算
|
|
69
71
|
x, y = @dialog_renderer.calculate_center(width, height)
|
|
70
72
|
|
|
73
|
+
# ウィンドウの位置とサイズを保存
|
|
74
|
+
@last_window = { x: x, y: y, width: width, height: height }
|
|
75
|
+
|
|
71
76
|
# フローティングウィンドウを描画
|
|
72
77
|
@dialog_renderer.draw_floating_window(x, y, width, height, title, content_lines, {
|
|
73
78
|
border_color: border_color,
|
|
@@ -136,6 +141,19 @@ module Rufio
|
|
|
136
141
|
@dialog_renderer.clear_area(x, y, width, height)
|
|
137
142
|
end
|
|
138
143
|
|
|
144
|
+
# コマンド入力プロンプトをクリア
|
|
145
|
+
def clear_prompt
|
|
146
|
+
return unless @last_window
|
|
147
|
+
|
|
148
|
+
@dialog_renderer.clear_area(
|
|
149
|
+
@last_window[:x],
|
|
150
|
+
@last_window[:y],
|
|
151
|
+
@last_window[:width],
|
|
152
|
+
@last_window[:height]
|
|
153
|
+
)
|
|
154
|
+
@last_window = nil
|
|
155
|
+
end
|
|
156
|
+
|
|
139
157
|
private
|
|
140
158
|
|
|
141
159
|
# 文字列配列の共通プレフィックスを見つける
|
|
@@ -7,6 +7,74 @@ module Rufio
|
|
|
7
7
|
class DialogRenderer
|
|
8
8
|
include TextUtils
|
|
9
9
|
|
|
10
|
+
# Phase 4: Screenバッファにフローティングウィンドウを描画
|
|
11
|
+
# @param screen [Screen] Screen buffer to draw to
|
|
12
|
+
# @param x [Integer] X position (column)
|
|
13
|
+
# @param y [Integer] Y position (row)
|
|
14
|
+
# @param width [Integer] Window width
|
|
15
|
+
# @param height [Integer] Window height
|
|
16
|
+
# @param title [String, nil] Window title (optional)
|
|
17
|
+
# @param content_lines [Array<String>] Content lines to display
|
|
18
|
+
# @param options [Hash] Customization options
|
|
19
|
+
# @option options [String] :border_color Border color ANSI code
|
|
20
|
+
# @option options [String] :title_color Title color ANSI code
|
|
21
|
+
# @option options [String] :content_color Content color ANSI code
|
|
22
|
+
def draw_floating_window_to_buffer(screen, x, y, width, height, title, content_lines, options = {})
|
|
23
|
+
# Default options
|
|
24
|
+
border_color = options[:border_color] || "\e[37m" # White
|
|
25
|
+
title_color = options[:title_color] || "\e[1;33m" # Bold yellow
|
|
26
|
+
content_color = options[:content_color] || "\e[37m" # White
|
|
27
|
+
|
|
28
|
+
# Draw top border
|
|
29
|
+
screen.put_string(x, y, "┌#{'─' * (width - 2)}┐", fg: border_color)
|
|
30
|
+
|
|
31
|
+
# Draw title line if title exists
|
|
32
|
+
if title
|
|
33
|
+
title_width = TextUtils.display_width(title)
|
|
34
|
+
title_padding = (width - 2 - title_width) / 2
|
|
35
|
+
padded_title = ' ' * title_padding + title
|
|
36
|
+
title_line = TextUtils.pad_string_to_width(padded_title, width - 2)
|
|
37
|
+
|
|
38
|
+
screen.put(x, y + 1, '│', fg: border_color)
|
|
39
|
+
screen.put_string(x + 1, y + 1, title_line, fg: title_color)
|
|
40
|
+
screen.put(x + width - 1, y + 1, '│', fg: border_color)
|
|
41
|
+
|
|
42
|
+
# Draw title separator
|
|
43
|
+
screen.put_string(x, y + 2, "├#{'─' * (width - 2)}┤", fg: border_color)
|
|
44
|
+
content_start_y = y + 3
|
|
45
|
+
else
|
|
46
|
+
content_start_y = y + 1
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Draw content lines
|
|
50
|
+
content_height = title ? height - 4 : height - 2
|
|
51
|
+
content_lines.each_with_index do |line, index|
|
|
52
|
+
break if index >= content_height
|
|
53
|
+
|
|
54
|
+
line_y = content_start_y + index
|
|
55
|
+
line_content = TextUtils.pad_string_to_width(line, width - 2)
|
|
56
|
+
|
|
57
|
+
screen.put(x, line_y, '│', fg: border_color)
|
|
58
|
+
screen.put_string(x + 1, line_y, line_content, fg: content_color)
|
|
59
|
+
screen.put(x + width - 1, line_y, '│', fg: border_color)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Fill remaining lines with empty space
|
|
63
|
+
remaining_lines = content_height - content_lines.length
|
|
64
|
+
remaining_lines.times do |i|
|
|
65
|
+
line_y = content_start_y + content_lines.length + i
|
|
66
|
+
empty_line = ' ' * (width - 2)
|
|
67
|
+
|
|
68
|
+
screen.put(x, line_y, '│', fg: border_color)
|
|
69
|
+
screen.put_string(x + 1, line_y, empty_line)
|
|
70
|
+
screen.put(x + width - 1, line_y, '│', fg: border_color)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Draw bottom border
|
|
74
|
+
bottom_y = y + height - 1
|
|
75
|
+
screen.put_string(x, bottom_y, "└#{'─' * (width - 2)}┘", fg: border_color)
|
|
76
|
+
end
|
|
77
|
+
|
|
10
78
|
# Draw a floating window with title, content, and customizable colors
|
|
11
79
|
# @param x [Integer] X position (column)
|
|
12
80
|
# @param y [Integer] Y position (row)
|
|
@@ -545,7 +545,7 @@ module Rufio
|
|
|
545
545
|
end
|
|
546
546
|
|
|
547
547
|
def exit_request
|
|
548
|
-
|
|
548
|
+
show_exit_confirmation
|
|
549
549
|
end
|
|
550
550
|
|
|
551
551
|
def fzf_search
|
|
@@ -1117,6 +1117,53 @@ module Rufio
|
|
|
1117
1117
|
end
|
|
1118
1118
|
end
|
|
1119
1119
|
|
|
1120
|
+
def show_exit_confirmation
|
|
1121
|
+
# コンテンツの準備
|
|
1122
|
+
title = 'Exit Confirmation'
|
|
1123
|
+
|
|
1124
|
+
content_lines = [
|
|
1125
|
+
'',
|
|
1126
|
+
'Are you sure you want to exit?',
|
|
1127
|
+
'',
|
|
1128
|
+
' [Y]es - Exit',
|
|
1129
|
+
' [N]o - Cancel',
|
|
1130
|
+
''
|
|
1131
|
+
]
|
|
1132
|
+
|
|
1133
|
+
# ダイアログのサイズ設定
|
|
1134
|
+
dialog_width = CONFIRMATION_DIALOG_WIDTH
|
|
1135
|
+
dialog_height = DIALOG_BORDER_HEIGHT + content_lines.length
|
|
1136
|
+
|
|
1137
|
+
# ダイアログの位置を中央に設定
|
|
1138
|
+
x, y = @dialog_renderer.calculate_center(dialog_width, dialog_height)
|
|
1139
|
+
|
|
1140
|
+
# ダイアログの描画(終了は黄色で表示)
|
|
1141
|
+
@dialog_renderer.draw_floating_window(x, y, dialog_width, dialog_height, title, content_lines, {
|
|
1142
|
+
border_color: "\e[33m", # 黄色(注意)
|
|
1143
|
+
title_color: "\e[1;33m", # 太字黄色
|
|
1144
|
+
content_color: "\e[37m" # 白色
|
|
1145
|
+
})
|
|
1146
|
+
|
|
1147
|
+
# キー入力待機
|
|
1148
|
+
loop do
|
|
1149
|
+
input = STDIN.getch.downcase
|
|
1150
|
+
|
|
1151
|
+
case input
|
|
1152
|
+
when 'y'
|
|
1153
|
+
# ダイアログをクリア
|
|
1154
|
+
@dialog_renderer.clear_area(x, y, dialog_width, dialog_height)
|
|
1155
|
+
@terminal_ui&.refresh_display # 画面を再描画
|
|
1156
|
+
return true
|
|
1157
|
+
when 'n', "\e", "\x03" # n, ESC, Ctrl+C
|
|
1158
|
+
# ダイアログをクリア
|
|
1159
|
+
@dialog_renderer.clear_area(x, y, dialog_width, dialog_height)
|
|
1160
|
+
@terminal_ui&.refresh_display # 画面を再描画
|
|
1161
|
+
return false
|
|
1162
|
+
end
|
|
1163
|
+
# 無効なキー入力の場合は再度ループ
|
|
1164
|
+
end
|
|
1165
|
+
end
|
|
1166
|
+
|
|
1120
1167
|
# パスを指定した長さに短縮
|
|
1121
1168
|
def shorten_path(path, max_length)
|
|
1122
1169
|
return path if path.length <= max_length
|
|
@@ -1455,7 +1502,7 @@ module Rufio
|
|
|
1455
1502
|
|
|
1456
1503
|
# プロジェクトモード中のキー処理
|
|
1457
1504
|
def handle_project_mode_key(key)
|
|
1458
|
-
case key
|
|
1505
|
+
result = case key
|
|
1459
1506
|
when "\e" # ESC - ログモードならプロジェクトモードに戻る、そうでなければ終了
|
|
1460
1507
|
if @in_log_mode
|
|
1461
1508
|
exit_log_mode
|
|
@@ -1507,6 +1554,10 @@ module Rufio
|
|
|
1507
1554
|
else
|
|
1508
1555
|
false
|
|
1509
1556
|
end
|
|
1557
|
+
|
|
1558
|
+
# キー処理後、プロジェクトモードの再描画をトリガー
|
|
1559
|
+
@terminal_ui&.trigger_project_mode_redraw if result && @in_project_mode
|
|
1560
|
+
result
|
|
1510
1561
|
end
|
|
1511
1562
|
|
|
1512
1563
|
# プロジェクトモード用のエントリ数取得
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rufio
|
|
4
|
+
module Plugins
|
|
5
|
+
# Hello コマンドを提供するプラグイン
|
|
6
|
+
# Rubyコードで挨拶を返す簡単な例
|
|
7
|
+
class Stop < Plugin
|
|
8
|
+
def name
|
|
9
|
+
'Stop'
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def description
|
|
13
|
+
'Rubyで実装された挨拶コマンドの例'
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def commands
|
|
17
|
+
{
|
|
18
|
+
stop: method(:say_hello)
|
|
19
|
+
}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
# 挨拶メッセージを返す
|
|
25
|
+
def say_hello
|
|
26
|
+
'stop 5seconds'
|
|
27
|
+
sleep 5
|
|
28
|
+
'done'
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rufio
|
|
4
|
+
# Renderer class - Front buffer for double buffering
|
|
5
|
+
#
|
|
6
|
+
# Manages the front buffer (what's currently displayed on screen)
|
|
7
|
+
# and performs differential rendering by comparing with the back buffer (Screen).
|
|
8
|
+
#
|
|
9
|
+
# Features:
|
|
10
|
+
# - Diff rendering: Only updates changed lines
|
|
11
|
+
# - Cursor positioning: Uses ANSI escape codes
|
|
12
|
+
# - Flush control: Ensures all output is displayed
|
|
13
|
+
#
|
|
14
|
+
class Renderer
|
|
15
|
+
def initialize(width, height, output: STDOUT)
|
|
16
|
+
@width = width
|
|
17
|
+
@height = height
|
|
18
|
+
@front = Array.new(height) { " " * width }
|
|
19
|
+
@output = output
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Render the screen with differential updates
|
|
23
|
+
#
|
|
24
|
+
# @param screen [Screen] The back buffer to render
|
|
25
|
+
def render(screen)
|
|
26
|
+
# Phase1: Only process dirty rows (rows that have changed)
|
|
27
|
+
screen.dirty_rows.each do |y|
|
|
28
|
+
line = screen.row(y)
|
|
29
|
+
next if line == @front[y] # Skip if content is actually the same
|
|
30
|
+
|
|
31
|
+
# Move cursor to line y (1-indexed) and output the line
|
|
32
|
+
@output.print "\e[#{y + 1};1H#{line}"
|
|
33
|
+
@front[y] = line
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Phase1: Clear dirty tracking after rendering
|
|
37
|
+
screen.clear_dirty
|
|
38
|
+
@output.flush
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Resize the front buffer
|
|
42
|
+
#
|
|
43
|
+
# @param width [Integer] New width
|
|
44
|
+
# @param height [Integer] New height
|
|
45
|
+
def resize(width, height)
|
|
46
|
+
@width = width
|
|
47
|
+
@height = height
|
|
48
|
+
@front = Array.new(height) { " " * width }
|
|
49
|
+
|
|
50
|
+
# Clear entire screen
|
|
51
|
+
@output.print "\e[2J\e[H"
|
|
52
|
+
@output.flush
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Clear the front buffer and screen
|
|
56
|
+
def clear
|
|
57
|
+
@front = Array.new(@height) { " " * @width }
|
|
58
|
+
|
|
59
|
+
# Clear screen and move cursor to home
|
|
60
|
+
@output.print "\e[2J\e[H"
|
|
61
|
+
@output.flush
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
data/lib/rufio/screen.rb
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
5
|
+
module Rufio
|
|
6
|
+
# Screen class - Back buffer for double buffering
|
|
7
|
+
#
|
|
8
|
+
# Manages a virtual screen buffer where each cell contains:
|
|
9
|
+
# - Character
|
|
10
|
+
# - Foreground color (ANSI code)
|
|
11
|
+
# - Background color (ANSI code)
|
|
12
|
+
# - Display width (for multibyte characters)
|
|
13
|
+
#
|
|
14
|
+
# Supports:
|
|
15
|
+
# - ASCII characters (width = 1)
|
|
16
|
+
# - Full-width characters (width = 2, e.g., Japanese, Chinese)
|
|
17
|
+
# - Emoji (width = 2+)
|
|
18
|
+
#
|
|
19
|
+
# Phase1 Optimizations:
|
|
20
|
+
# - Width pre-calculation (computed once in put method)
|
|
21
|
+
# - Dirty row tracking (only render changed rows)
|
|
22
|
+
# - Optimized format_cell (String.new with capacity)
|
|
23
|
+
# - Optimized row generation (width accumulation, no ANSI strip)
|
|
24
|
+
# - Minimal ANSI stripping (only once in put_string)
|
|
25
|
+
#
|
|
26
|
+
class Screen
|
|
27
|
+
attr_reader :width, :height
|
|
28
|
+
|
|
29
|
+
def initialize(width, height)
|
|
30
|
+
@width = width
|
|
31
|
+
@height = height
|
|
32
|
+
@cells = Array.new(height) { Array.new(width) { default_cell } }
|
|
33
|
+
@dirty_rows = Set.new # Phase1: Dirty row tracking
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Put a single character at (x, y) with optional color
|
|
37
|
+
#
|
|
38
|
+
# @param x [Integer] X position (0-indexed)
|
|
39
|
+
# @param y [Integer] Y position (0-indexed)
|
|
40
|
+
# @param char [String] Character to put
|
|
41
|
+
# @param fg [String, nil] Foreground ANSI color code
|
|
42
|
+
# @param bg [String, nil] Background ANSI color code
|
|
43
|
+
# @param width [Integer, nil] Display width (auto-detected if not provided)
|
|
44
|
+
def put(x, y, char, fg: nil, bg: nil, width: nil)
|
|
45
|
+
return if out_of_bounds?(x, y)
|
|
46
|
+
|
|
47
|
+
# Phase1: Width is calculated once here (not in rendering loop)
|
|
48
|
+
char_width = width || TextUtils.display_width(char)
|
|
49
|
+
@cells[y][x] = {
|
|
50
|
+
char: char,
|
|
51
|
+
fg: fg,
|
|
52
|
+
bg: bg,
|
|
53
|
+
width: char_width
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# Phase1: Mark row as dirty
|
|
57
|
+
@dirty_rows.add(y)
|
|
58
|
+
|
|
59
|
+
# For full-width characters, mark the next cell as occupied
|
|
60
|
+
if char_width >= 2 && x + 1 < @width
|
|
61
|
+
(char_width - 1).times do |offset|
|
|
62
|
+
next_x = x + 1 + offset
|
|
63
|
+
break if next_x >= @width
|
|
64
|
+
@cells[y][next_x] = {
|
|
65
|
+
char: '',
|
|
66
|
+
fg: nil,
|
|
67
|
+
bg: nil,
|
|
68
|
+
width: 0
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Put a string starting at (x, y)
|
|
75
|
+
#
|
|
76
|
+
# @param x [Integer] Starting X position
|
|
77
|
+
# @param y [Integer] Y position
|
|
78
|
+
# @param str [String] String to put (ANSI codes will be stripped)
|
|
79
|
+
# @param fg [String, nil] Foreground ANSI color code
|
|
80
|
+
# @param bg [String, nil] Background ANSI color code
|
|
81
|
+
def put_string(x, y, str, fg: nil, bg: nil)
|
|
82
|
+
return if out_of_bounds?(x, y)
|
|
83
|
+
|
|
84
|
+
# Phase1: ANSI stripping only once (minimal processing)
|
|
85
|
+
# Only strip if the string contains ANSI codes
|
|
86
|
+
clean_str = str.include?("\e") ? ColorHelper.strip_ansi(str) : str
|
|
87
|
+
|
|
88
|
+
current_x = x
|
|
89
|
+
clean_str.each_char do |char|
|
|
90
|
+
break if current_x >= @width
|
|
91
|
+
|
|
92
|
+
char_width = TextUtils.display_width(char)
|
|
93
|
+
put(current_x, y, char, fg: fg, bg: bg, width: char_width)
|
|
94
|
+
current_x += char_width
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Get the cell at (x, y)
|
|
99
|
+
#
|
|
100
|
+
# @param x [Integer] X position
|
|
101
|
+
# @param y [Integer] Y position
|
|
102
|
+
# @return [Hash] Cell data {char:, fg:, bg:, width:}
|
|
103
|
+
def get_cell(x, y)
|
|
104
|
+
return default_cell if out_of_bounds?(x, y)
|
|
105
|
+
@cells[y][x]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Get a row as a formatted string
|
|
109
|
+
#
|
|
110
|
+
# @param y [Integer] Row number
|
|
111
|
+
# @return [String] Formatted row with ANSI codes
|
|
112
|
+
def row(y)
|
|
113
|
+
return " " * @width if y < 0 || y >= @height
|
|
114
|
+
|
|
115
|
+
# Phase1: Pre-allocate string capacity for better performance
|
|
116
|
+
result = String.new(capacity: @width * 20)
|
|
117
|
+
current_width = 0 # Phase1: Accumulate width from cells (no recalculation)
|
|
118
|
+
|
|
119
|
+
@cells[y].each do |cell|
|
|
120
|
+
# Skip marker cells for full-width characters
|
|
121
|
+
next if cell[:width] == 0
|
|
122
|
+
|
|
123
|
+
result << format_cell(cell)
|
|
124
|
+
current_width += cell[:width] # Phase1: Use pre-calculated width
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Pad the row to full width
|
|
128
|
+
# Phase1: No ANSI stripping or width recalculation needed
|
|
129
|
+
if current_width < @width
|
|
130
|
+
result << (" " * (@width - current_width))
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
result
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Clear the entire screen
|
|
137
|
+
def clear
|
|
138
|
+
@cells.each do |row|
|
|
139
|
+
row.fill { default_cell }
|
|
140
|
+
end
|
|
141
|
+
# Phase1: Clear dirty rows after full clear
|
|
142
|
+
@dirty_rows.clear
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Phase1: Get dirty rows (rows that have been modified since last clear)
|
|
146
|
+
#
|
|
147
|
+
# @return [Array<Integer>] Array of dirty row indices
|
|
148
|
+
def dirty_rows
|
|
149
|
+
@dirty_rows.to_a
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Phase1: Clear dirty row tracking
|
|
153
|
+
def clear_dirty
|
|
154
|
+
@dirty_rows.clear
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
private
|
|
158
|
+
|
|
159
|
+
def default_cell
|
|
160
|
+
{ char: ' ', fg: nil, bg: nil, width: 1 }
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def out_of_bounds?(x, y)
|
|
164
|
+
x < 0 || y < 0 || x >= @width || y >= @height
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def format_cell(cell)
|
|
168
|
+
char = cell[:char]
|
|
169
|
+
fg = cell[:fg]
|
|
170
|
+
bg = cell[:bg]
|
|
171
|
+
|
|
172
|
+
# Phase1: Fast path for cells without color
|
|
173
|
+
return char if fg.nil? && bg.nil?
|
|
174
|
+
|
|
175
|
+
# Phase1: String builder with pre-allocated capacity (no array generation)
|
|
176
|
+
result = String.new(capacity: 30)
|
|
177
|
+
result << fg if fg
|
|
178
|
+
result << bg if bg
|
|
179
|
+
result << char
|
|
180
|
+
result << "\e[0m"
|
|
181
|
+
result
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|