ruby_rich 0.3.1 → 0.4.1
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/columns.rb +244 -0
- data/lib/ruby_rich/composer.rb +512 -0
- data/lib/ruby_rich/console.rb +191 -29
- 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 +213 -0
- data/lib/ruby_rich/panel.rb +1 -1
- data/lib/ruby_rich/print.rb +6 -6
- data/lib/ruby_rich/progress_bar.rb +229 -11
- 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/status.rb +246 -0
- data/lib/ruby_rich/syntax.rb +171 -0
- data/lib/ruby_rich/table.rb +167 -21
- data/lib/ruby_rich/terminal.rb +510 -0
- data/lib/ruby_rich/text.rb +112 -2
- 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/tree.rb +200 -0
- data/lib/ruby_rich/version.rb +1 -1
- data/lib/ruby_rich/viewport.rb +468 -0
- data/lib/ruby_rich.rb +65 -10
- metadata +93 -3
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
require 'redcarpet'
|
|
2
|
+
|
|
3
|
+
module RubyRich
|
|
4
|
+
class Markdown
|
|
5
|
+
# Converts markdown to ANSI-styled terminal output.
|
|
6
|
+
# Uses Redcarpet for block parsing with custom inline processing.
|
|
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
|
+
|
|
24
|
+
def initialize(options = {})
|
|
25
|
+
@options = {
|
|
26
|
+
width: 80,
|
|
27
|
+
indent: ' '
|
|
28
|
+
}.merge(options)
|
|
29
|
+
super()
|
|
30
|
+
reset_table_state
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def reset_table_state
|
|
34
|
+
@table_state = { current_row: [], all_rows: [] }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# ---- block-level callbacks ----
|
|
38
|
+
|
|
39
|
+
def paragraph(text)
|
|
40
|
+
"#{process_inline(text)}\n\n"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def header(text, level)
|
|
44
|
+
processed = process_inline(text)
|
|
45
|
+
case level
|
|
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"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def block_code(code, language)
|
|
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"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def codespan(code)
|
|
64
|
+
"#{AnsiCode.background(:white)}#{AnsiCode.color(:black)} #{code} #{AnsiCode.reset}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def block_quote(quote)
|
|
68
|
+
lines = quote.strip.split("\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"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def list_item(text, list_type)
|
|
74
|
+
marker = list_type == :ordered ? '1.' : '•'
|
|
75
|
+
"#{AnsiCode.color(:cyan, true)}#{marker}#{AnsiCode.reset} #{process_inline(text.strip)}\n"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def list(contents, list_type)
|
|
79
|
+
"#{contents}\n"
|
|
80
|
+
end
|
|
81
|
+
|
|
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}"
|
|
85
|
+
|
|
86
|
+
def link(link, title, content)
|
|
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}"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def image(link, title, alt_text)
|
|
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}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def hrule
|
|
97
|
+
"#{AnsiCode.color(:black, true)}#{"─" * @options[:width]}#{AnsiCode.reset}\n\n"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def linebreak = "\n"
|
|
101
|
+
|
|
102
|
+
# ---- table callbacks ----
|
|
103
|
+
|
|
104
|
+
def table(header, body)
|
|
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) }
|
|
116
|
+
begin
|
|
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])
|
|
122
|
+
end
|
|
123
|
+
return "#{tbl.render}\n\n"
|
|
124
|
+
rescue
|
|
125
|
+
# fallback
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
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"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def table_row(content)
|
|
136
|
+
@table_state[:all_rows] << @table_state[:current_row].dup
|
|
137
|
+
@table_state[:current_row] = []
|
|
138
|
+
"#{content}\n"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def table_cell(content, alignment)
|
|
142
|
+
@table_state[:current_row] << content.strip
|
|
143
|
+
content
|
|
144
|
+
end
|
|
145
|
+
|
|
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
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def self.codespan_compat(code)
|
|
159
|
+
"#{AnsiCode.background(:white)}#{AnsiCode.color(:black)} #{code} #{AnsiCode.reset}"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def wrap_text(text, width = nil)
|
|
163
|
+
width ||= @options[:width]
|
|
164
|
+
return text if text.length <= width
|
|
165
|
+
|
|
166
|
+
words = text.split(' ')
|
|
167
|
+
lines = []
|
|
168
|
+
current_line = ''
|
|
169
|
+
|
|
170
|
+
words.each do |word|
|
|
171
|
+
if (current_line + ' ' + word).length <= width
|
|
172
|
+
current_line += current_line.empty? ? word : ' ' + word
|
|
173
|
+
else
|
|
174
|
+
lines << current_line unless current_line.empty?
|
|
175
|
+
current_line = word
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
lines << current_line unless current_line.empty?
|
|
180
|
+
lines.join("\n")
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def indent_lines(text)
|
|
184
|
+
text.split("\n").map { |line| "#{@options[:indent]}#{line}" }.join("\n")
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def visible_width(text)
|
|
188
|
+
text.gsub(/\e\[[0-9;]*m/, '').length
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def self.render(markdown_text, options = {})
|
|
193
|
+
renderer = TerminalRenderer.new(options)
|
|
194
|
+
markdown_processor = Redcarpet::Markdown.new(renderer, {
|
|
195
|
+
fenced_code_blocks: true,
|
|
196
|
+
tables: true,
|
|
197
|
+
autolink: true,
|
|
198
|
+
strikethrough: true,
|
|
199
|
+
space_after_headers: true
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
markdown_processor.render(markdown_text)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def initialize(options = {})
|
|
206
|
+
@options = options
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def render(markdown_text)
|
|
210
|
+
self.class.render(markdown_text, @options)
|
|
211
|
+
end
|
|
212
|
+
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
|
|
@@ -1,29 +1,247 @@
|
|
|
1
1
|
module RubyRich
|
|
2
2
|
class ProgressBar
|
|
3
3
|
|
|
4
|
-
attr_reader :progress
|
|
4
|
+
attr_reader :progress, :total, :start_time
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
# 进度条样式
|
|
7
|
+
STYLES = {
|
|
8
|
+
default: { filled: '=', empty: ' ', prefix: '[', suffix: ']' },
|
|
9
|
+
blocks: { filled: '█', empty: '░', prefix: '[', suffix: ']' },
|
|
10
|
+
arrows: { filled: '>', empty: '-', prefix: '[', suffix: ']' },
|
|
11
|
+
dots: { filled: '●', empty: '○', prefix: '(', suffix: ')' },
|
|
12
|
+
line: { filled: '━', empty: '─', prefix: '│', suffix: '│' },
|
|
13
|
+
gradient: { filled: ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'], empty: ' ', prefix: '[', suffix: ']' }
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
def initialize(total, width: 50, style: :default, title: nil, show_percentage: true, show_rate: false, show_eta: false)
|
|
7
17
|
@total = total
|
|
8
18
|
@progress = 0
|
|
9
19
|
@width = width
|
|
10
20
|
@style = style
|
|
21
|
+
@title = title
|
|
22
|
+
@show_percentage = show_percentage
|
|
23
|
+
@show_rate = show_rate
|
|
24
|
+
@show_eta = show_eta
|
|
25
|
+
@start_time = nil
|
|
26
|
+
@last_update_time = nil
|
|
27
|
+
@update_history = []
|
|
11
28
|
end
|
|
12
29
|
|
|
13
|
-
def
|
|
30
|
+
def start
|
|
31
|
+
@start_time = Time.now
|
|
32
|
+
@last_update_time = @start_time
|
|
33
|
+
render
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def advance(amount = 1)
|
|
37
|
+
@start_time ||= Time.now
|
|
14
38
|
@progress += amount
|
|
15
|
-
@progress = @
|
|
39
|
+
@progress = [@progress, @total].min
|
|
40
|
+
|
|
41
|
+
current_time = Time.now
|
|
42
|
+
@update_history << { time: current_time, progress: @progress }
|
|
43
|
+
|
|
44
|
+
# 保留最近的几个更新用于计算速率
|
|
45
|
+
@update_history = @update_history.last(10)
|
|
46
|
+
@last_update_time = current_time
|
|
47
|
+
|
|
16
48
|
render
|
|
49
|
+
puts if completed?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def set_progress(value)
|
|
53
|
+
@start_time ||= Time.now
|
|
54
|
+
@progress = [[value, 0].max, @total].min
|
|
55
|
+
|
|
56
|
+
current_time = Time.now
|
|
57
|
+
@update_history << { time: current_time, progress: @progress }
|
|
58
|
+
@update_history = @update_history.last(10)
|
|
59
|
+
@last_update_time = current_time
|
|
60
|
+
|
|
61
|
+
render
|
|
62
|
+
puts if completed?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def completed?
|
|
66
|
+
@progress >= @total
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def percentage
|
|
70
|
+
return 0 if @total == 0
|
|
71
|
+
(@progress.to_f / @total * 100).round(1)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def elapsed_time
|
|
75
|
+
return 0 unless @start_time
|
|
76
|
+
Time.now - @start_time
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def rate
|
|
80
|
+
return 0 if @update_history.length < 2
|
|
81
|
+
|
|
82
|
+
first_update = @update_history.first
|
|
83
|
+
last_update = @update_history.last
|
|
84
|
+
|
|
85
|
+
time_diff = last_update[:time] - first_update[:time]
|
|
86
|
+
progress_diff = last_update[:progress] - first_update[:progress]
|
|
87
|
+
|
|
88
|
+
return 0 if time_diff == 0
|
|
89
|
+
progress_diff / time_diff
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def eta
|
|
93
|
+
return 0 if rate == 0 || completed?
|
|
94
|
+
remaining = @total - @progress
|
|
95
|
+
remaining / rate
|
|
17
96
|
end
|
|
18
97
|
|
|
19
98
|
def render
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
99
|
+
bar_content = render_bar
|
|
100
|
+
status_text = render_status
|
|
101
|
+
|
|
102
|
+
output = ""
|
|
103
|
+
output << "\e[94m#{@title}: \e[0m" if @title
|
|
104
|
+
output << bar_content
|
|
105
|
+
output << " #{status_text}" unless status_text.empty?
|
|
106
|
+
|
|
107
|
+
print "\r\e[K#{output}"
|
|
108
|
+
$stdout.flush
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def finish(message: nil)
|
|
112
|
+
if message
|
|
113
|
+
puts "\r\e[K#{message}"
|
|
114
|
+
else
|
|
115
|
+
puts
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# 静态方法:创建带回调的进度条
|
|
120
|
+
def self.with_progress(total, **options)
|
|
121
|
+
bar = new(total, **options)
|
|
122
|
+
bar.start
|
|
123
|
+
|
|
124
|
+
begin
|
|
125
|
+
yield(bar) if block_given?
|
|
126
|
+
ensure
|
|
127
|
+
bar.finish
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
bar
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# 多进度条管理器
|
|
134
|
+
class MultiProgress
|
|
135
|
+
def initialize
|
|
136
|
+
@bars = []
|
|
137
|
+
@active = false
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def add(title, total, **options)
|
|
141
|
+
bar = ProgressBar.new(total, title: title, **options)
|
|
142
|
+
@bars << bar
|
|
143
|
+
bar
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def start
|
|
147
|
+
@active = true
|
|
148
|
+
render_all
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def render_all
|
|
152
|
+
return unless @active
|
|
153
|
+
|
|
154
|
+
print "\e[#{@bars.length}A" unless @bars.empty? # 移动光标到顶部
|
|
155
|
+
|
|
156
|
+
@bars.each_with_index do |bar, index|
|
|
157
|
+
bar_output = render_bar_line(bar)
|
|
158
|
+
puts "\e[K#{bar_output}"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
$stdout.flush
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def finish_all
|
|
165
|
+
@active = false
|
|
166
|
+
puts
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
private
|
|
170
|
+
|
|
171
|
+
def render_bar_line(bar)
|
|
172
|
+
bar_content = bar.send(:render_bar)
|
|
173
|
+
status_text = bar.send(:render_status)
|
|
174
|
+
|
|
175
|
+
output = ""
|
|
176
|
+
output << "\e[94m#{bar.instance_variable_get(:@title)}: \e[0m" if bar.instance_variable_get(:@title)
|
|
177
|
+
output << bar_content
|
|
178
|
+
output << " #{status_text}" unless status_text.empty?
|
|
179
|
+
output
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
private
|
|
184
|
+
|
|
185
|
+
def render_bar
|
|
186
|
+
style_config = STYLES[@style] || STYLES[:default]
|
|
187
|
+
|
|
188
|
+
completed_width = (@progress.to_f / @total * @width).round
|
|
189
|
+
|
|
190
|
+
if @style == :gradient
|
|
191
|
+
filled_chars = style_config[:filled]
|
|
192
|
+
filled_full = filled_chars.last * (completed_width / filled_chars.length)
|
|
193
|
+
filled_partial = filled_chars[completed_width % filled_chars.length] if completed_width % filled_chars.length > 0
|
|
194
|
+
filled = filled_full + (filled_partial || '')
|
|
195
|
+
empty = style_config[:empty] * (@width - filled.length)
|
|
196
|
+
else
|
|
197
|
+
filled = style_config[:filled] * completed_width
|
|
198
|
+
empty = style_config[:empty] * (@width - completed_width)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# 添加颜色
|
|
202
|
+
color_filled = completed? ? "\e[92m" : "\e[96m" # 完成时绿色,进行中蓝色
|
|
203
|
+
color_empty = "\e[90m" # 空白部分灰色
|
|
204
|
+
color_reset = "\e[0m"
|
|
205
|
+
|
|
206
|
+
"#{style_config[:prefix]}#{color_filled}#{filled}#{color_empty}#{empty}#{color_reset}#{style_config[:suffix]}"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def render_status
|
|
210
|
+
parts = []
|
|
211
|
+
|
|
212
|
+
if @show_percentage
|
|
213
|
+
parts << "#{percentage}%"
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
parts << "(#{@progress}/#{@total})"
|
|
217
|
+
|
|
218
|
+
if @show_rate && rate > 0
|
|
219
|
+
parts << sprintf("%.1f/s", rate)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
if @show_eta && eta > 0 && !completed?
|
|
223
|
+
eta_formatted = format_time(eta)
|
|
224
|
+
parts << "ETA: #{eta_formatted}"
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
if @start_time
|
|
228
|
+
elapsed_formatted = format_time(elapsed_time)
|
|
229
|
+
parts << "#{elapsed_formatted}"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
parts.join(" ")
|
|
233
|
+
end
|
|
23
234
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
235
|
+
def format_time(seconds)
|
|
236
|
+
if seconds < 60
|
|
237
|
+
sprintf("%.1fs", seconds)
|
|
238
|
+
elsif seconds < 3600
|
|
239
|
+
minutes = seconds / 60
|
|
240
|
+
sprintf("%.1fm", minutes)
|
|
241
|
+
else
|
|
242
|
+
hours = seconds / 3600
|
|
243
|
+
sprintf("%.1fh", hours)
|
|
244
|
+
end
|
|
27
245
|
end
|
|
28
246
|
end
|
|
29
|
-
end
|
|
247
|
+
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
|