ruby_rich 0.4.0 → 0.4.2
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/lib/ruby_rich/agent_shell.rb +254 -0
- data/lib/ruby_rich/ansi_code.rb +46 -0
- data/lib/ruby_rich/app_shell.rb +374 -0
- data/lib/ruby_rich/attachment.rb +25 -0
- data/lib/ruby_rich/composer.rb +512 -0
- data/lib/ruby_rich/console.rb +174 -25
- data/lib/ruby_rich/dialog.rb +2 -1
- data/lib/ruby_rich/event.rb +29 -0
- data/lib/ruby_rich/focus_manager.rb +77 -0
- data/lib/ruby_rich/layout.rb +117 -29
- data/lib/ruby_rich/line_editor.rb +325 -0
- data/lib/ruby_rich/live.rb +100 -19
- data/lib/ruby_rich/markdown.rb +100 -230
- data/lib/ruby_rich/panel.rb +1 -1
- data/lib/ruby_rich/print.rb +6 -6
- data/lib/ruby_rich/progress_manager.rb +150 -0
- data/lib/ruby_rich/sidebar.rb +85 -0
- data/lib/ruby_rich/slash_input.rb +197 -0
- data/lib/ruby_rich/table.rb +12 -12
- data/lib/ruby_rich/terminal.rb +510 -0
- data/lib/ruby_rich/text.rb +1 -1
- data/lib/ruby_rich/theme.rb +96 -0
- data/lib/ruby_rich/tool_block.rb +92 -0
- data/lib/ruby_rich/transcript.rb +553 -0
- data/lib/ruby_rich/version.rb +1 -1
- data/lib/ruby_rich/viewport.rb +468 -0
- data/lib/ruby_rich.rb +38 -13
- metadata +32 -25
data/lib/ruby_rich/markdown.rb
CHANGED
|
@@ -2,193 +2,171 @@ require 'redcarpet'
|
|
|
2
2
|
|
|
3
3
|
module RubyRich
|
|
4
4
|
class Markdown
|
|
5
|
-
#
|
|
5
|
+
# Converts markdown to ANSI-styled terminal output.
|
|
6
|
+
# Uses Redcarpet for block parsing with custom inline processing.
|
|
6
7
|
class TerminalRenderer < Redcarpet::Render::Base
|
|
8
|
+
INLINE_MARKERS = {
|
|
9
|
+
# triple-backtick must come before double-backtick
|
|
10
|
+
%r{```(.+?)```}m => ->(m) { codespan_compat(Regexp.last_match(1)) },
|
|
11
|
+
%r{``(.+?)``}m => ->(m) { codespan_compat(Regexp.last_match(1)) },
|
|
12
|
+
%r{`(.+?)`} => ->(m) { codespan_compat(Regexp.last_match(1)) },
|
|
13
|
+
%r{\*\*\*(.+?)\*\*\*} => ->(m) { "#{AnsiCode.bold}#{AnsiCode.italic}#{Regexp.last_match(1)}#{AnsiCode.reset}" },
|
|
14
|
+
%r{\*\*(.+?)\*\*} => ->(m) { "#{AnsiCode.bold}#{Regexp.last_match(1)}#{AnsiCode.reset}" },
|
|
15
|
+
%r{(?<!\*)\*([^*]+)\*(?!\*)} => ->(m) { "#{AnsiCode.italic}#{Regexp.last_match(1)}#{AnsiCode.reset}" },
|
|
16
|
+
%r{~~(.+?)~~} => ->(m) { "#{AnsiCode.strikethrough}#{Regexp.last_match(1)}#{AnsiCode.reset}" },
|
|
17
|
+
%r{\[([^\]]+)\]\(([^)]+)\)} => ->(m) {
|
|
18
|
+
link_text = Regexp.last_match(1)
|
|
19
|
+
url = Regexp.last_match(2)
|
|
20
|
+
"#{AnsiCode.color(:blue, true)}#{AnsiCode.underline}#{link_text}#{AnsiCode.reset} #{AnsiCode.color(:black, true)}(#{url})#{AnsiCode.reset}"
|
|
21
|
+
}
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
7
24
|
def initialize(options = {})
|
|
8
25
|
@options = {
|
|
9
26
|
width: 80,
|
|
10
27
|
indent: ' '
|
|
11
28
|
}.merge(options)
|
|
12
29
|
super()
|
|
13
|
-
|
|
14
|
-
# 表格状态
|
|
15
30
|
reset_table_state
|
|
16
31
|
end
|
|
17
32
|
|
|
18
33
|
def reset_table_state
|
|
19
|
-
@table_state = {
|
|
20
|
-
in_table: false,
|
|
21
|
-
headers: [],
|
|
22
|
-
current_row: [],
|
|
23
|
-
rows: []
|
|
24
|
-
}
|
|
34
|
+
@table_state = { current_row: [], all_rows: [] }
|
|
25
35
|
end
|
|
26
36
|
|
|
27
|
-
#
|
|
37
|
+
# ---- block-level callbacks ----
|
|
38
|
+
|
|
28
39
|
def paragraph(text)
|
|
29
|
-
|
|
40
|
+
"#{process_inline(text)}\n\n"
|
|
30
41
|
end
|
|
31
42
|
|
|
32
|
-
# 标题
|
|
33
43
|
def header(text, level)
|
|
44
|
+
processed = process_inline(text)
|
|
34
45
|
case level
|
|
35
|
-
when 1
|
|
36
|
-
|
|
37
|
-
when
|
|
38
|
-
|
|
39
|
-
when 3
|
|
40
|
-
"\e[1m\e[93m### #{text}\e[0m\n\n"
|
|
41
|
-
else
|
|
42
|
-
"\e[1m\e[90m#{'#' * level} #{text}\e[0m\n\n"
|
|
46
|
+
when 1 then "#{AnsiCode.font(:cyan, font_bright: true, bold: true)}#{processed}#{AnsiCode.reset}\n#{AnsiCode.color(:cyan, true)}#{'=' * visible_width(text)}#{AnsiCode.reset}\n\n"
|
|
47
|
+
when 2 then "#{AnsiCode.font(:blue, font_bright: true, bold: true)}#{processed}#{AnsiCode.reset}\n#{AnsiCode.color(:blue, true)}#{'-' * visible_width(text)}#{AnsiCode.reset}\n\n"
|
|
48
|
+
when 3 then "#{AnsiCode.font(:yellow, font_bright: true, bold: true)}### #{processed}#{AnsiCode.reset}\n\n"
|
|
49
|
+
else "#{AnsiCode.font(:black, font_bright: true, bold: true)}#{'#' * level} #{processed}#{AnsiCode.reset}\n\n"
|
|
43
50
|
end
|
|
44
51
|
end
|
|
45
52
|
|
|
46
|
-
# 代码块
|
|
47
53
|
def block_code(code, language)
|
|
48
|
-
|
|
49
|
-
|
|
54
|
+
lang = language&.strip
|
|
55
|
+
lang = nil if lang && lang.empty?
|
|
56
|
+
highlighted = Syntax.highlight(code.strip, lang)
|
|
57
|
+
bg = AnsiCode.background(:black, true)
|
|
58
|
+
fg = AnsiCode.color(:white, true)
|
|
59
|
+
pad = @options[:indent]
|
|
60
|
+
"#{bg}#{fg}#{indent_lines(highlighted)}#{AnsiCode.reset}\n\n"
|
|
50
61
|
end
|
|
51
62
|
|
|
52
|
-
# 行内代码
|
|
53
63
|
def codespan(code)
|
|
54
|
-
"
|
|
64
|
+
"#{AnsiCode.background(:white)}#{AnsiCode.color(:black)} #{code} #{AnsiCode.reset}"
|
|
55
65
|
end
|
|
56
66
|
|
|
57
|
-
# 引用
|
|
58
67
|
def block_quote(quote)
|
|
59
68
|
lines = quote.strip.split("\n")
|
|
60
|
-
quoted_lines = lines.map { |line| "
|
|
61
|
-
quoted_lines.join("\n")
|
|
69
|
+
quoted_lines = lines.map { |line| "#{AnsiCode.color(:black, true)}│ #{AnsiCode.color(:white, true)}#{process_inline(line.strip)}" }
|
|
70
|
+
"#{quoted_lines.join("\n")}#{AnsiCode.reset}\n\n"
|
|
62
71
|
end
|
|
63
72
|
|
|
64
|
-
# 列表项
|
|
65
73
|
def list_item(text, list_type)
|
|
66
74
|
marker = list_type == :ordered ? '1.' : '•'
|
|
67
|
-
"
|
|
75
|
+
"#{AnsiCode.color(:cyan, true)}#{marker}#{AnsiCode.reset} #{process_inline(text.strip)}\n"
|
|
68
76
|
end
|
|
69
77
|
|
|
70
|
-
# 无序列表
|
|
71
78
|
def list(contents, list_type)
|
|
72
|
-
contents
|
|
79
|
+
"#{contents}\n"
|
|
73
80
|
end
|
|
74
81
|
|
|
75
|
-
#
|
|
76
|
-
def
|
|
77
|
-
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
# 加粗
|
|
81
|
-
def double_emphasis(text)
|
|
82
|
-
"\e[1m#{text}\e[22m"
|
|
83
|
-
end
|
|
82
|
+
def emphasis(text) = "#{AnsiCode.italic}#{text}#{AnsiCode.reset}"
|
|
83
|
+
def double_emphasis(text) = "#{AnsiCode.bold}#{text}#{AnsiCode.reset}"
|
|
84
|
+
def strikethrough(text) = "#{AnsiCode.strikethrough}#{text}#{AnsiCode.reset}"
|
|
84
85
|
|
|
85
|
-
# 删除线
|
|
86
|
-
def strikethrough(text)
|
|
87
|
-
"\e[9m#{text}\e[29m"
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
# 链接
|
|
91
86
|
def link(link, title, content)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
else
|
|
95
|
-
"\e[94m\e[4m#{content}\e[24m\e[0m \e[90m(#{link})\e[0m"
|
|
96
|
-
end
|
|
87
|
+
title_part = title && !title.empty? ? " - #{title}" : ""
|
|
88
|
+
"#{AnsiCode.color(:blue, true)}#{AnsiCode.underline}#{content}#{AnsiCode.reset} #{AnsiCode.color(:black, true)}(#{link}#{title_part})#{AnsiCode.reset}"
|
|
97
89
|
end
|
|
98
90
|
|
|
99
|
-
# 图片
|
|
100
91
|
def image(link, title, alt_text)
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
else
|
|
104
|
-
"\e[95m[Image: #{alt_text}]\e[0m \e[90m(#{link})\e[0m"
|
|
105
|
-
end
|
|
92
|
+
title_part = title && !title.empty? ? " - #{title}" : ""
|
|
93
|
+
"#{AnsiCode.color(:magenta, true)}[Image: #{alt_text}]#{AnsiCode.reset} #{AnsiCode.color(:black, true)}(#{link}#{title_part})#{AnsiCode.reset}"
|
|
106
94
|
end
|
|
107
95
|
|
|
108
|
-
# 水平线
|
|
109
96
|
def hrule
|
|
110
|
-
"
|
|
97
|
+
"#{AnsiCode.color(:black, true)}#{"─" * @options[:width]}#{AnsiCode.reset}\n\n"
|
|
111
98
|
end
|
|
112
99
|
|
|
113
|
-
|
|
100
|
+
def linebreak = "\n"
|
|
101
|
+
|
|
102
|
+
# ---- table callbacks ----
|
|
103
|
+
|
|
114
104
|
def table(header, body)
|
|
115
|
-
|
|
116
|
-
|
|
105
|
+
all_rows = @table_state[:all_rows]
|
|
106
|
+
reset_table_state
|
|
107
|
+
return "" if all_rows.empty?
|
|
108
|
+
|
|
109
|
+
header_line_count = [header.to_s.strip.split("\n").size, 1].max
|
|
110
|
+
header_rows = all_rows[0...header_line_count]
|
|
111
|
+
body_rows = all_rows[header_line_count..] || []
|
|
112
|
+
|
|
113
|
+
return "" if header_rows.empty? || body_rows.empty?
|
|
114
|
+
|
|
115
|
+
headers = header_rows.last.map { |c| process_inline(c) }
|
|
117
116
|
begin
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
# 从header中提取列标题
|
|
124
|
-
header_content = header.strip
|
|
125
|
-
# 尝试按常见模式分割(大写字母开头的单词)
|
|
126
|
-
headers = split_table_content_intelligently(header_content)
|
|
127
|
-
end
|
|
128
|
-
|
|
129
|
-
if body && !body.strip.empty?
|
|
130
|
-
body_lines = body.strip.split("\n").reject(&:empty?)
|
|
131
|
-
body_lines.each do |line|
|
|
132
|
-
row_data = split_table_content_intelligently(line.strip)
|
|
133
|
-
rows << row_data unless row_data.all?(&:empty?)
|
|
134
|
-
end
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
# 如果成功解析出了数据,使用RubyRich表格
|
|
138
|
-
if !headers.empty? && !rows.empty?
|
|
139
|
-
table = RubyRich::Table.new(
|
|
140
|
-
headers: headers,
|
|
141
|
-
border_style: @options[:table_border_style] || :simple
|
|
142
|
-
)
|
|
143
|
-
|
|
144
|
-
rows.each do |row|
|
|
145
|
-
# 确保行长度与标题长度一致
|
|
146
|
-
padded_row = row + Array.new([0, headers.length - row.length].max, "")
|
|
147
|
-
table.add_row(padded_row[0...headers.length])
|
|
148
|
-
end
|
|
149
|
-
|
|
150
|
-
return table.render + "\n\n"
|
|
117
|
+
tbl = RubyRich::Table.new(headers: headers, border_style: @options[:table_border_style] || :simple)
|
|
118
|
+
body_rows.each do |row|
|
|
119
|
+
processed = row.map { |c| process_inline(c) }
|
|
120
|
+
padded = processed + Array.new([0, headers.length - processed.length].max, "")
|
|
121
|
+
tbl.add_row(padded[0...headers.length])
|
|
151
122
|
end
|
|
152
|
-
|
|
153
|
-
rescue
|
|
154
|
-
#
|
|
123
|
+
return "#{tbl.render}\n\n"
|
|
124
|
+
rescue
|
|
125
|
+
# fallback
|
|
155
126
|
end
|
|
156
|
-
|
|
157
|
-
# Fallback: 简单显示
|
|
127
|
+
|
|
158
128
|
result = "\n"
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
if body && !body.strip.empty?
|
|
164
|
-
result += body.strip + "\n"
|
|
165
|
-
end
|
|
166
|
-
result + "\n"
|
|
129
|
+
result += "#{header.strip}\n"
|
|
130
|
+
result += "#{"-" * [header.strip.length, 20].min}\n"
|
|
131
|
+
result += "#{body.strip}\n" if body && !body.strip.empty?
|
|
132
|
+
"#{result}\n"
|
|
167
133
|
end
|
|
168
134
|
|
|
169
135
|
def table_row(content)
|
|
170
|
-
|
|
136
|
+
@table_state[:all_rows] << @table_state[:current_row].dup
|
|
137
|
+
@table_state[:current_row] = []
|
|
138
|
+
"#{content}\n"
|
|
171
139
|
end
|
|
172
140
|
|
|
173
141
|
def table_cell(content, alignment)
|
|
142
|
+
@table_state[:current_row] << content.strip
|
|
174
143
|
content
|
|
175
144
|
end
|
|
176
145
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
146
|
+
private
|
|
147
|
+
|
|
148
|
+
def process_inline(text)
|
|
149
|
+
return text if text.nil? || text.empty?
|
|
150
|
+
|
|
151
|
+
result = text.dup
|
|
152
|
+
INLINE_MARKERS.each do |regex, handler|
|
|
153
|
+
result.gsub!(regex, &handler)
|
|
154
|
+
end
|
|
155
|
+
result
|
|
180
156
|
end
|
|
181
157
|
|
|
182
|
-
|
|
158
|
+
def self.codespan_compat(code)
|
|
159
|
+
"#{AnsiCode.background(:white)}#{AnsiCode.color(:black)} #{code} #{AnsiCode.reset}"
|
|
160
|
+
end
|
|
183
161
|
|
|
184
162
|
def wrap_text(text, width = nil)
|
|
185
163
|
width ||= @options[:width]
|
|
186
164
|
return text if text.length <= width
|
|
187
|
-
|
|
165
|
+
|
|
188
166
|
words = text.split(' ')
|
|
189
167
|
lines = []
|
|
190
168
|
current_line = ''
|
|
191
|
-
|
|
169
|
+
|
|
192
170
|
words.each do |word|
|
|
193
171
|
if (current_line + ' ' + word).length <= width
|
|
194
172
|
current_line += current_line.empty? ? word : ' ' + word
|
|
@@ -197,125 +175,17 @@ module RubyRich
|
|
|
197
175
|
current_line = word
|
|
198
176
|
end
|
|
199
177
|
end
|
|
200
|
-
|
|
178
|
+
|
|
201
179
|
lines << current_line unless current_line.empty?
|
|
202
180
|
lines.join("\n")
|
|
203
181
|
end
|
|
204
182
|
|
|
205
183
|
def indent_lines(text)
|
|
206
|
-
text.split("\n").map { |line| @options[:indent]
|
|
184
|
+
text.split("\n").map { |line| "#{@options[:indent]}#{line}" }.join("\n")
|
|
207
185
|
end
|
|
208
186
|
|
|
209
|
-
def
|
|
210
|
-
|
|
211
|
-
return [] if lines.empty?
|
|
212
|
-
|
|
213
|
-
rows = []
|
|
214
|
-
lines.each do |line|
|
|
215
|
-
# 跳过分隔符行(如 |------|--------|)
|
|
216
|
-
next if line.match?(/^\|[\s\-\|:]+\|?$/)
|
|
217
|
-
|
|
218
|
-
# 解析表格行
|
|
219
|
-
if line.start_with?('|') && line.end_with?('|')
|
|
220
|
-
cells = line[1..-2].split('|').map(&:strip)
|
|
221
|
-
rows << cells unless cells.empty?
|
|
222
|
-
elsif line.include?('|')
|
|
223
|
-
cells = line.split('|').map(&:strip)
|
|
224
|
-
rows << cells unless cells.empty?
|
|
225
|
-
end
|
|
226
|
-
end
|
|
227
|
-
|
|
228
|
-
rows
|
|
229
|
-
end
|
|
230
|
-
|
|
231
|
-
def render_table_with_ruby_rich(rows)
|
|
232
|
-
return "" if rows.empty?
|
|
233
|
-
|
|
234
|
-
# 使用第一行作为表头
|
|
235
|
-
headers = rows.first
|
|
236
|
-
data_rows = rows[1..-1] || []
|
|
237
|
-
|
|
238
|
-
# 创建RubyRich表格,使用简单边框样式来匹配markdown风格
|
|
239
|
-
table = RubyRich::Table.new(
|
|
240
|
-
headers: headers,
|
|
241
|
-
border_style: @options[:table_border_style] || :simple
|
|
242
|
-
)
|
|
243
|
-
|
|
244
|
-
# 添加数据行
|
|
245
|
-
data_rows.each do |row|
|
|
246
|
-
# 确保行长度与标题长度一致
|
|
247
|
-
padded_row = row + Array.new([0, headers.length - row.length].max, "")
|
|
248
|
-
table.add_row(padded_row)
|
|
249
|
-
end
|
|
250
|
-
|
|
251
|
-
table.render + "\n\n"
|
|
252
|
-
rescue => e
|
|
253
|
-
# 如果表格渲染失败,使用简单的文字格式
|
|
254
|
-
fallback_table_render(rows)
|
|
255
|
-
end
|
|
256
|
-
|
|
257
|
-
def fallback_table_render(rows)
|
|
258
|
-
return "" if rows.empty?
|
|
259
|
-
|
|
260
|
-
result = []
|
|
261
|
-
rows.each_with_index do |row, index|
|
|
262
|
-
result << "| " + row.join(" | ") + " |"
|
|
263
|
-
if index == 0 # 在标题下添加分隔线
|
|
264
|
-
result << "|" + ("-" * (row.join(" | ").length + 2)) + "|"
|
|
265
|
-
end
|
|
266
|
-
end
|
|
267
|
-
|
|
268
|
-
result.join("\n") + "\n\n"
|
|
269
|
-
end
|
|
270
|
-
|
|
271
|
-
def split_table_content_intelligently(content)
|
|
272
|
-
return [] if content.nil? || content.strip.empty?
|
|
273
|
-
|
|
274
|
-
# 策略1:更智能的分割 - 先尝试找到数字字母边界
|
|
275
|
-
split_points = []
|
|
276
|
-
content.each_char.with_index do |char, index|
|
|
277
|
-
if index > 0 && index < content.length - 1
|
|
278
|
-
prev_char = content[index - 1]
|
|
279
|
-
# 在字母后跟数字,或数字后跟字母的地方分割
|
|
280
|
-
if (prev_char =~ /[A-Za-z]/ && char =~ /\d/) ||
|
|
281
|
-
(prev_char =~ /\d/ && char =~ /[A-Za-z]/)
|
|
282
|
-
split_points << index
|
|
283
|
-
end
|
|
284
|
-
end
|
|
285
|
-
end
|
|
286
|
-
|
|
287
|
-
if split_points.any?
|
|
288
|
-
parts = []
|
|
289
|
-
last_pos = 0
|
|
290
|
-
split_points.each do |pos|
|
|
291
|
-
parts << content[last_pos...pos] if pos > last_pos
|
|
292
|
-
last_pos = pos
|
|
293
|
-
end
|
|
294
|
-
parts << content[last_pos..-1] if last_pos < content.length
|
|
295
|
-
result = parts.reject(&:empty?)
|
|
296
|
-
return result if result.length >= 2
|
|
297
|
-
end
|
|
298
|
-
|
|
299
|
-
# 策略2:如果没有数字字母边界,按大写字母分割(但要小心短词)
|
|
300
|
-
words = content.scan(/[A-Z][a-z]+|[A-Z]{2,}/)
|
|
301
|
-
if words.length >= 2 && words.all? { |w| w.length >= 2 }
|
|
302
|
-
return words
|
|
303
|
-
end
|
|
304
|
-
|
|
305
|
-
# 策略3:按连续的字母和数字分组
|
|
306
|
-
parts = content.scan(/[A-Za-z]+|\d+/)
|
|
307
|
-
if parts.length >= 2
|
|
308
|
-
return parts
|
|
309
|
-
end
|
|
310
|
-
|
|
311
|
-
# 策略4:按空格分割
|
|
312
|
-
space_parts = content.split(/\s+/).reject(&:empty?)
|
|
313
|
-
if space_parts.length >= 2
|
|
314
|
-
return space_parts
|
|
315
|
-
end
|
|
316
|
-
|
|
317
|
-
# 最后的fallback:返回原始内容
|
|
318
|
-
[content]
|
|
187
|
+
def visible_width(text)
|
|
188
|
+
text.gsub(/\e\[[0-9;]*m/, '').length
|
|
319
189
|
end
|
|
320
190
|
end
|
|
321
191
|
|
|
@@ -328,7 +198,7 @@ module RubyRich
|
|
|
328
198
|
strikethrough: true,
|
|
329
199
|
space_after_headers: true
|
|
330
200
|
})
|
|
331
|
-
|
|
201
|
+
|
|
332
202
|
markdown_processor.render(markdown_text)
|
|
333
203
|
end
|
|
334
204
|
|
|
@@ -340,4 +210,4 @@ module RubyRich
|
|
|
340
210
|
self.class.render(markdown_text, @options)
|
|
341
211
|
end
|
|
342
212
|
end
|
|
343
|
-
end
|
|
213
|
+
end
|
data/lib/ruby_rich/panel.rb
CHANGED
data/lib/ruby_rich/print.rb
CHANGED
|
@@ -7,15 +7,15 @@ module RubyRich
|
|
|
7
7
|
def print(*args)
|
|
8
8
|
processed_args = args.map do |arg|
|
|
9
9
|
next arg unless arg.is_a?(String)
|
|
10
|
-
|
|
11
|
-
#
|
|
10
|
+
|
|
11
|
+
# Handle emoji
|
|
12
12
|
text = if arg.start_with?(':') && arg.end_with?(':')
|
|
13
13
|
Emoji.find_by_alias(arg[1..-2])&.raw || arg
|
|
14
14
|
else
|
|
15
15
|
arg
|
|
16
16
|
end
|
|
17
|
-
|
|
18
|
-
#
|
|
17
|
+
|
|
18
|
+
# Handle style markers
|
|
19
19
|
while text.match?(@style_regex)
|
|
20
20
|
text = text.gsub(@style_regex) do |_|
|
|
21
21
|
style, content = $1, $2
|
|
@@ -52,9 +52,9 @@ module RubyRich
|
|
|
52
52
|
end
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
-
#
|
|
55
|
+
# Create global print method
|
|
56
56
|
$rich_print = RichPrint.new
|
|
57
57
|
def print(*args)
|
|
58
58
|
$rich_print.print(*args)
|
|
59
|
-
end
|
|
59
|
+
end
|
|
60
60
|
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module RubyRich
|
|
6
|
+
class ProgressManager
|
|
7
|
+
FRAMES = %w[| / - \\].freeze
|
|
8
|
+
|
|
9
|
+
class Handle
|
|
10
|
+
attr_reader :id, :owner, :message, :state
|
|
11
|
+
|
|
12
|
+
def initialize(manager, id:, owner:, message:)
|
|
13
|
+
@manager = manager
|
|
14
|
+
@id = id
|
|
15
|
+
@owner = owner
|
|
16
|
+
@message = message
|
|
17
|
+
@state = :running
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def update(message)
|
|
21
|
+
return false unless active?
|
|
22
|
+
|
|
23
|
+
@message = message.to_s
|
|
24
|
+
@manager.update(@id, @owner, @message)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def finish(message = "Done")
|
|
28
|
+
close(:done, message)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def fail(message = "Failed")
|
|
32
|
+
close(:error, message)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def cancel(message = "Cancelled")
|
|
36
|
+
close(:cancelled, message)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def active?
|
|
40
|
+
@state == :running
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def close(state, message)
|
|
46
|
+
return false unless active?
|
|
47
|
+
|
|
48
|
+
@state = state
|
|
49
|
+
@message = message.to_s
|
|
50
|
+
@manager.finish(@id, @owner, state, @message)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def initialize(on_change: nil)
|
|
55
|
+
@stack = []
|
|
56
|
+
@mutex = Mutex.new
|
|
57
|
+
@on_change = on_change
|
|
58
|
+
@frame = 0
|
|
59
|
+
@ticker = nil
|
|
60
|
+
@running = false
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def start(message, owner: Thread.current.object_id)
|
|
64
|
+
handle = Handle.new(self, id: SecureRandom.hex(6), owner: owner, message: message.to_s)
|
|
65
|
+
@mutex.synchronize { @stack << handle }
|
|
66
|
+
start_ticker
|
|
67
|
+
notify
|
|
68
|
+
handle
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def update(id, owner, message)
|
|
72
|
+
ok = @mutex.synchronize do
|
|
73
|
+
handle = @stack.find { |item| item.id == id && item.owner == owner && item.active? }
|
|
74
|
+
next false unless handle
|
|
75
|
+
|
|
76
|
+
handle.instance_variable_set(:@message, message.to_s)
|
|
77
|
+
true
|
|
78
|
+
end
|
|
79
|
+
notify if ok
|
|
80
|
+
ok
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def finish(id, owner, state, message)
|
|
84
|
+
ok = @mutex.synchronize do
|
|
85
|
+
handle = @stack.find { |item| item.id == id && item.owner == owner }
|
|
86
|
+
next false unless handle
|
|
87
|
+
|
|
88
|
+
handle.instance_variable_set(:@state, state)
|
|
89
|
+
handle.instance_variable_set(:@message, message.to_s)
|
|
90
|
+
@stack.delete(handle)
|
|
91
|
+
true
|
|
92
|
+
end
|
|
93
|
+
notify if ok
|
|
94
|
+
stop_ticker_if_idle
|
|
95
|
+
ok
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def current
|
|
99
|
+
@mutex.synchronize { @stack.last }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def render
|
|
103
|
+
handle = current
|
|
104
|
+
return nil unless handle
|
|
105
|
+
|
|
106
|
+
frame = FRAMES[@frame % FRAMES.length]
|
|
107
|
+
"#{frame} #{handle.message}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def with_progress(message)
|
|
111
|
+
handle = start(message)
|
|
112
|
+
begin
|
|
113
|
+
yield handle
|
|
114
|
+
handle.finish
|
|
115
|
+
rescue Exception => e
|
|
116
|
+
handle.fail(e.message)
|
|
117
|
+
raise
|
|
118
|
+
ensure
|
|
119
|
+
handle.cancel if handle.active?
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
def notify
|
|
126
|
+
@on_change&.call(render)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def start_ticker
|
|
130
|
+
return if @ticker&.alive?
|
|
131
|
+
|
|
132
|
+
@running = true
|
|
133
|
+
@ticker = Thread.new do
|
|
134
|
+
while @running
|
|
135
|
+
sleep 0.12
|
|
136
|
+
@frame += 1
|
|
137
|
+
notify
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def stop_ticker_if_idle
|
|
143
|
+
return if current
|
|
144
|
+
|
|
145
|
+
@running = false
|
|
146
|
+
@ticker&.kill
|
|
147
|
+
@ticker = nil
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|