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.
@@ -2,193 +2,171 @@ require 'redcarpet'
2
2
 
3
3
  module RubyRich
4
4
  class Markdown
5
- # 简化的 Markdown 渲染器,将 Markdown 转换为带 ANSI 颜色的终端输出
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
- wrap_text(text) + "\n\n"
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
- "\e[1m\e[96m#{text}\e[0m\n" + "\e[96m#{'=' * text.length}\e[0m\n\n"
37
- when 2
38
- "\e[1m\e[94m#{text}\e[0m\n" + "\e[94m#{'-' * text.length}\e[0m\n\n"
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
- "\e[100m\e[37m" + indent_lines(code.strip) + "\e[0m\n\n"
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
- "\e[47m\e[30m #{code} \e[0m"
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| "\e[90m\e[37m#{line.strip}" }
61
- quoted_lines.join("\n") + "\e[0m\n\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
- "\e[96m#{marker}\e[0m #{text.strip}\n"
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 + "\n"
79
+ "#{contents}\n"
73
80
  end
74
81
 
75
- # 强调
76
- def emphasis(text)
77
- "\e[3m#{text}\e[23m"
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
- if title && !title.empty?
93
- "\e[94m\e[4m#{content}\e[24m\e[0m \e[90m(#{link} - #{title})\e[0m"
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
- if title && !title.empty?
102
- "\e[95m[Image: #{alt_text}]\e[0m \e[90m(#{link} - #{title})\e[0m"
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
- "\e[90m" + "─" * @options[:width] + "\e[0m\n\n"
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
- return "" if header.nil? && body.nil?
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
- headers = []
120
- rows = []
121
-
122
- if header && !header.strip.empty?
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 => e
154
- # 如果出错,继续使用fallback
123
+ return "#{tbl.render}\n\n"
124
+ rescue
125
+ # fallback
155
126
  end
156
-
157
- # Fallback: 简单显示
127
+
158
128
  result = "\n"
159
- if header && !header.strip.empty?
160
- result += "#{header.strip}\n"
161
- result += "-" * [header.strip.length, 20].min + "\n"
162
- end
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
- content + "\n"
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
- def linebreak
179
- "\n"
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
- private
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] + line }.join("\n")
184
+ text.split("\n").map { |line| "#{@options[:indent]}#{line}" }.join("\n")
207
185
  end
208
186
 
209
- def parse_table_content(content)
210
- lines = content.strip.split("\n").map(&:strip).reject(&:empty?)
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
@@ -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
@@ -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