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.
@@ -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
@@ -33,7 +33,7 @@ module RubyRich
33
33
  @line_pos = content_lines.size - @height + 2
34
34
  end
35
35
  @content_changed = false
36
- end
36
+ end
37
37
  end
38
38
 
39
39
  def home
@@ -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
- def initialize(total, width: 50, style: :default)
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 advance(amount)
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 = @total if @progress > @total
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
- percentage = (@progress.to_f / @total * 100).to_i
21
- completed_width = (@progress.to_f / @total * @width).to_i
22
- incomplete_width = @width - completed_width
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
- bar = "[#{"=" * completed_width}#{" " * incomplete_width}]"
25
- print "\r#{bar} #{percentage}%"
26
- puts if @progress == @total
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