ruby_rich 0.4.7 → 0.4.9
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/markdown.rb +443 -141
- data/lib/ruby_rich/transcript.rb +2 -0
- data/lib/ruby_rich/version.rb +1 -1
- data/lib/ruby_rich/viewport.rb +75 -15
- data/lib/ruby_rich.rb +1 -1
- metadata +19 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 852764b68219de1ce528f772a55c3e454b3556c35aa9f0c0e89c29f31c2293ba
|
|
4
|
+
data.tar.gz: e2827430cb5dada9ae7d8ca1cb59d9fbca4df95790404c1bcb893e7323864b95
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 95388b58b409347a55ebd1ef3dded1d3520810c7f31292bb83e14dd73192f356ec453b5688dd9dc122ae1e626ff365d6738c2d9a4de799adf53df2b2cced8916
|
|
7
|
+
data.tar.gz: 342ed768c7a9f04fadc1da1ec1d775fbbbdf482fc446f55ed69b4eb880159118cd36f6860c11964ddcc7371e70bcc35adcca0203183fcc558a604bd43786091e
|
data/lib/ruby_rich/markdown.rb
CHANGED
|
@@ -1,213 +1,515 @@
|
|
|
1
|
-
require '
|
|
1
|
+
require 'kramdown'
|
|
2
2
|
|
|
3
3
|
module RubyRich
|
|
4
4
|
class Markdown
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
5
|
+
# 将 Markdown 转换为 ANSI 终端输出。
|
|
6
|
+
# 使用 kramdown 的 AST 遍历 + 自定义 Converter 实现。
|
|
7
|
+
#
|
|
8
|
+
# kramdown 比 redcarpet 的优势:
|
|
9
|
+
# - 纯 Ruby 实现,无需 C 扩展编译
|
|
10
|
+
# - 原生 GFM 支持(表格、任务列表、删除线)
|
|
11
|
+
# - 定义列表 (definition lists)
|
|
12
|
+
# - 脚注 (footnotes)
|
|
13
|
+
# - 数学公式 (需 math engine)
|
|
14
|
+
# - 缩写 (abbreviations)
|
|
15
|
+
# - 活跃维护
|
|
16
|
+
class TerminalConverter < Kramdown::Converter::Base
|
|
17
|
+
|
|
18
|
+
def initialize(root, options)
|
|
19
|
+
super
|
|
20
|
+
@width = options[:width] || 80
|
|
21
|
+
@indent_str = options[:indent] || ' '
|
|
22
|
+
@table_border_style = options[:table_border_style] || :simple
|
|
23
|
+
# 用于有序列表编号
|
|
24
|
+
@list_counters = []
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# 主分发方法 — 根据 AST 元素类型路由到对应处理方法
|
|
28
|
+
def convert(el, _indent = 0)
|
|
29
|
+
case el.type
|
|
30
|
+
when :root then convert_children(el)
|
|
31
|
+
when :blank then "\n"
|
|
32
|
+
when :text then el.value
|
|
33
|
+
when :p then convert_p(el)
|
|
34
|
+
when :header then convert_header(el)
|
|
35
|
+
when :codeblock then convert_codeblock(el)
|
|
36
|
+
when :codespan then convert_codespan(el)
|
|
37
|
+
when :blockquote then convert_blockquote(el)
|
|
38
|
+
when :ul then convert_list(el)
|
|
39
|
+
when :ol then convert_list(el)
|
|
40
|
+
when :li then convert_li(el)
|
|
41
|
+
when :em then convert_em(el)
|
|
42
|
+
when :strong then convert_strong(el)
|
|
43
|
+
when :em_strong then convert_em_strong(el)
|
|
44
|
+
when :a then convert_link(el)
|
|
45
|
+
when :img then convert_image(el)
|
|
46
|
+
when :hr then convert_hr(el)
|
|
47
|
+
when :br then "\n"
|
|
48
|
+
when :table then convert_table(el)
|
|
49
|
+
when :thead then convert_children(el)
|
|
50
|
+
when :tbody then convert_children(el)
|
|
51
|
+
when :tr then convert_table_row(el)
|
|
52
|
+
when :th then convert_table_cell(el)
|
|
53
|
+
when :td then convert_table_cell(el)
|
|
54
|
+
when :html_element then convert_html_element(el)
|
|
55
|
+
when :html_entity then convert_html_entity(el)
|
|
56
|
+
when :smart_quote then convert_smart_quote(el)
|
|
57
|
+
when :entity then el.value.to_s
|
|
58
|
+
when :raw then convert_raw(el)
|
|
59
|
+
when :comment then ''
|
|
60
|
+
when :footnote then convert_footnote(el)
|
|
61
|
+
when :dl then convert_definition_list(el)
|
|
62
|
+
when :dt then convert_definition_term(el)
|
|
63
|
+
when :dd then convert_definition_desc(el)
|
|
64
|
+
when :abbreviation then convert_abbreviation(el)
|
|
65
|
+
when :math then convert_math(el)
|
|
66
|
+
else
|
|
67
|
+
# 未知类型,递归处理子元素
|
|
68
|
+
if el.children && !el.children.empty?
|
|
69
|
+
convert_children(el)
|
|
70
|
+
else
|
|
71
|
+
el.value || el.text || ''
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
# ---- 辅助方法 ----
|
|
79
|
+
|
|
80
|
+
def convert_children(el)
|
|
81
|
+
return '' unless el.children
|
|
82
|
+
el.children.map { |child| convert(child) }.join
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def inline_content(el)
|
|
86
|
+
return '' unless el.children
|
|
87
|
+
el.children.map { |child| convert(child) }.join
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def visible_width(text)
|
|
91
|
+
text.to_s.gsub(/\e\[[0-9;]*m/, '').length
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# ---- 块级元素 ----
|
|
95
|
+
|
|
96
|
+
def convert_p(el)
|
|
97
|
+
"#{inline_content(el)}\n\n"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def convert_header(el)
|
|
101
|
+
level = el.options[:level] || 1
|
|
102
|
+
text = inline_content(el)
|
|
103
|
+
vw = visible_width(text)
|
|
45
104
|
case level
|
|
46
|
-
when 1
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
105
|
+
when 1
|
|
106
|
+
"#{AnsiCode.font(:cyan, font_bright: true, bold: true)}#{text}#{AnsiCode.reset}\n" \
|
|
107
|
+
"#{AnsiCode.color(:cyan, true)}#{'=' * [vw, 1].max}#{AnsiCode.reset}\n\n"
|
|
108
|
+
when 2
|
|
109
|
+
"#{AnsiCode.font(:blue, font_bright: true, bold: true)}#{text}#{AnsiCode.reset}\n" \
|
|
110
|
+
"#{AnsiCode.color(:blue, true)}#{'-' * [vw, 1].max}#{AnsiCode.reset}\n\n"
|
|
111
|
+
when 3
|
|
112
|
+
"#{AnsiCode.font(:yellow, font_bright: true, bold: true)}### #{text}#{AnsiCode.reset}\n\n"
|
|
113
|
+
else
|
|
114
|
+
prefix = '#' * level
|
|
115
|
+
"#{AnsiCode.font(:black, font_bright: true, bold: true)}#{prefix} #{text}#{AnsiCode.reset}\n\n"
|
|
50
116
|
end
|
|
51
117
|
end
|
|
52
118
|
|
|
53
|
-
def
|
|
54
|
-
lang =
|
|
119
|
+
def convert_codeblock(el)
|
|
120
|
+
lang = el.options[:lang]&.strip
|
|
55
121
|
lang = nil if lang && lang.empty?
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
122
|
+
code = el.value.strip
|
|
123
|
+
highlighted = Syntax.highlight(code, lang)
|
|
124
|
+
bg = AnsiCode.background(:black, true)
|
|
125
|
+
fg = AnsiCode.color(:white, true)
|
|
60
126
|
"#{bg}#{fg}#{indent_lines(highlighted)}#{AnsiCode.reset}\n\n"
|
|
61
127
|
end
|
|
62
128
|
|
|
63
|
-
def
|
|
129
|
+
def convert_codespan(el)
|
|
130
|
+
code = el.value
|
|
64
131
|
"#{AnsiCode.background(:white)}#{AnsiCode.color(:black)} #{code} #{AnsiCode.reset}"
|
|
65
132
|
end
|
|
66
133
|
|
|
67
|
-
def
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
134
|
+
def convert_blockquote(el)
|
|
135
|
+
content = inline_content(el)
|
|
136
|
+
lines = content.strip.split("\n")
|
|
137
|
+
quoted = lines.map do |line|
|
|
138
|
+
"#{AnsiCode.color(:black, true)}│ #{AnsiCode.color(:white, true)}#{strip_ansi_reset(line)}"
|
|
139
|
+
end
|
|
140
|
+
"#{quoted.join("\n")}#{AnsiCode.reset}\n\n"
|
|
71
141
|
end
|
|
72
142
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
143
|
+
# ---- 列表 ----
|
|
144
|
+
|
|
145
|
+
def convert_list(el)
|
|
146
|
+
# 保存/恢复计数器,支持嵌套列表
|
|
147
|
+
@list_counters.push(0)
|
|
148
|
+
result = convert_children(el)
|
|
149
|
+
@list_counters.pop
|
|
150
|
+
"#{result}\n"
|
|
76
151
|
end
|
|
77
152
|
|
|
78
|
-
def
|
|
79
|
-
|
|
153
|
+
def convert_li(el)
|
|
154
|
+
marker = if el.options[:parent]&.type == :ol
|
|
155
|
+
@list_counters[-1] += 1
|
|
156
|
+
"#{@list_counters[-1]}."
|
|
157
|
+
else
|
|
158
|
+
'•'
|
|
159
|
+
end
|
|
160
|
+
text = inline_content(el)
|
|
161
|
+
"#{AnsiCode.color(:cyan, true)}#{marker}#{AnsiCode.reset} #{text.strip}\n"
|
|
80
162
|
end
|
|
81
163
|
|
|
82
|
-
|
|
83
|
-
|
|
164
|
+
# ---- 内联样式 ----
|
|
165
|
+
|
|
166
|
+
def convert_em(el)
|
|
167
|
+
"#{AnsiCode.italic}#{inline_content(el)}#{AnsiCode.reset}"
|
|
84
168
|
end
|
|
85
|
-
|
|
86
|
-
|
|
169
|
+
|
|
170
|
+
def convert_strong(el)
|
|
171
|
+
"#{AnsiCode.bold}#{inline_content(el)}#{AnsiCode.reset}"
|
|
87
172
|
end
|
|
88
|
-
|
|
89
|
-
|
|
173
|
+
|
|
174
|
+
def convert_em_strong(el)
|
|
175
|
+
"#{AnsiCode.bold}#{AnsiCode.italic}#{inline_content(el)}#{AnsiCode.reset}"
|
|
90
176
|
end
|
|
91
177
|
|
|
92
|
-
|
|
178
|
+
# ---- 链接与图片 ----
|
|
179
|
+
|
|
180
|
+
def convert_link(el)
|
|
181
|
+
url = el.attr['href'] || ''
|
|
182
|
+
title = el.attr['title']
|
|
183
|
+
text = inline_content(el)
|
|
93
184
|
title_part = title && !title.empty? ? " - #{title}" : ""
|
|
94
|
-
"#{AnsiCode.color(:blue, true)}#{AnsiCode.underline}#{
|
|
185
|
+
"#{AnsiCode.color(:blue, true)}#{AnsiCode.underline}#{text}#{AnsiCode.reset} " \
|
|
186
|
+
"#{AnsiCode.color(:black, true)}(#{url}#{title_part})#{AnsiCode.reset}"
|
|
95
187
|
end
|
|
96
188
|
|
|
97
|
-
def
|
|
189
|
+
def convert_image(el)
|
|
190
|
+
url = el.attr['src'] || ''
|
|
191
|
+
title = el.attr['title']
|
|
192
|
+
alt = el.attr['alt'] || ''
|
|
98
193
|
title_part = title && !title.empty? ? " - #{title}" : ""
|
|
99
|
-
"#{AnsiCode.color(:magenta, true)}[Image: #{
|
|
194
|
+
"#{AnsiCode.color(:magenta, true)}[Image: #{alt}]#{AnsiCode.reset} " \
|
|
195
|
+
"#{AnsiCode.color(:black, true)}(#{url}#{title_part})#{AnsiCode.reset}"
|
|
100
196
|
end
|
|
101
197
|
|
|
102
|
-
|
|
103
|
-
"#{AnsiCode.color(:black, true)}#{"─" * @options[:width]}#{AnsiCode.reset}\n\n"
|
|
104
|
-
end
|
|
198
|
+
# ---- 水平线 ----
|
|
105
199
|
|
|
106
|
-
def
|
|
107
|
-
|
|
200
|
+
def convert_hr(_el)
|
|
201
|
+
line_char = '─'
|
|
202
|
+
"#{AnsiCode.color(:black, true)}#{line_char * @width}#{AnsiCode.reset}\n\n"
|
|
108
203
|
end
|
|
109
204
|
|
|
110
|
-
# ----
|
|
111
|
-
|
|
112
|
-
def
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
205
|
+
# ---- 表格(kramdown 原生 AST) ----
|
|
206
|
+
|
|
207
|
+
def convert_table(el)
|
|
208
|
+
# 收集表头行和表体行
|
|
209
|
+
header_rows = []
|
|
210
|
+
body_rows = []
|
|
211
|
+
el.children.each do |section|
|
|
212
|
+
case section.type
|
|
213
|
+
when :thead
|
|
214
|
+
section.children.each { |tr| header_rows << collect_row_cells(tr) }
|
|
215
|
+
when :tbody
|
|
216
|
+
section.children.each { |tr| body_rows << collect_row_cells(tr) }
|
|
217
|
+
when :tr
|
|
218
|
+
body_rows << collect_row_cells(section)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
120
221
|
|
|
121
222
|
return "" if header_rows.empty? || body_rows.empty?
|
|
122
223
|
|
|
123
|
-
headers = header_rows.last
|
|
224
|
+
headers, fitted_body_rows = fit_table_rows(header_rows.last, body_rows)
|
|
124
225
|
begin
|
|
125
|
-
tbl = RubyRich::Table.new(
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
226
|
+
tbl = RubyRich::Table.new(
|
|
227
|
+
headers: headers,
|
|
228
|
+
border_style: @table_border_style || :simple
|
|
229
|
+
)
|
|
230
|
+
fitted_body_rows.each do |row|
|
|
231
|
+
padded = row + Array.new([0, headers.length - row.length].max, "")
|
|
129
232
|
tbl.add_row(padded[0...headers.length])
|
|
130
233
|
end
|
|
131
|
-
|
|
234
|
+
"#{tbl.render}\n\n"
|
|
132
235
|
rescue
|
|
133
|
-
# fallback
|
|
236
|
+
# fallback: plain text table
|
|
237
|
+
result = "\n"
|
|
238
|
+
result += header_rows.last.join(" | ")
|
|
239
|
+
result += "\n#{"-" * [result.strip.length, 20].min}\n"
|
|
240
|
+
body_rows.each { |row| result += row.join(" | ") + "\n" }
|
|
241
|
+
return "#{result}\n"
|
|
134
242
|
end
|
|
243
|
+
end
|
|
135
244
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
"#{result}\n"
|
|
245
|
+
# Extract cell text from a table row element.
|
|
246
|
+
def collect_row_cells(tr)
|
|
247
|
+
tr.children.select { |c| [:th, :td].include?(c.type) }
|
|
248
|
+
.map { |c| inline_content(c) }
|
|
141
249
|
end
|
|
142
250
|
|
|
143
|
-
def
|
|
144
|
-
|
|
145
|
-
@table_state[:current_row] = []
|
|
146
|
-
"#{content}\n"
|
|
251
|
+
def convert_table_row(el)
|
|
252
|
+
convert_children(el)
|
|
147
253
|
end
|
|
148
254
|
|
|
149
|
-
def
|
|
150
|
-
|
|
151
|
-
content
|
|
255
|
+
def convert_table_cell(el)
|
|
256
|
+
inline_content(el)
|
|
152
257
|
end
|
|
153
258
|
|
|
154
|
-
|
|
259
|
+
# ---- 表格宽度自适应 ----
|
|
260
|
+
|
|
261
|
+
# Fit table cell content to terminal width by normalising column counts,
|
|
262
|
+
# calculating natural widths, constraining to available space, and wrapping
|
|
263
|
+
# cell text.
|
|
264
|
+
def fit_table_rows(header_row, body_rows)
|
|
265
|
+
column_count = [header_row.length, *body_rows.map(&:length)].max.to_i
|
|
266
|
+
normalized_header = header_row + Array.new([0, column_count - header_row.length].max, "")
|
|
267
|
+
normalized_body = body_rows.map { |row| row + Array.new([0, column_count - row.length].max, "") }
|
|
268
|
+
natural_widths = table_natural_widths(normalized_header, normalized_body)
|
|
269
|
+
column_widths = constrain_table_widths(natural_widths)
|
|
270
|
+
|
|
271
|
+
headers = normalized_header.each_with_index.map { |cell, index| wrap_table_cell(table_cell_text(cell), column_widths[index]) }
|
|
272
|
+
rows = normalized_body.map do |row|
|
|
273
|
+
row.each_with_index.map { |cell, index| wrap_table_cell(table_cell_text(cell), column_widths[index]) }
|
|
274
|
+
end
|
|
155
275
|
|
|
156
|
-
|
|
157
|
-
|
|
276
|
+
[headers, rows]
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Maximum display width per column.
|
|
280
|
+
def table_natural_widths(header_row, body_rows)
|
|
281
|
+
rows = [header_row] + body_rows
|
|
282
|
+
return [] if rows.empty?
|
|
158
283
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
result.gsub!(regex, &handler)
|
|
284
|
+
rows.transpose.map do |cells|
|
|
285
|
+
cells.map { |cell| cell_display_width(table_cell_text(cell)) }.max.to_i
|
|
162
286
|
end
|
|
163
|
-
result
|
|
164
287
|
end
|
|
165
288
|
|
|
166
|
-
|
|
167
|
-
|
|
289
|
+
# Strip ANSI escape sequences from a cell value.
|
|
290
|
+
def table_cell_text(cell)
|
|
291
|
+
cell.to_s.gsub(/\e\[[0-9;:]*m/, "")
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Shrink column widths proportionally to fit the terminal width.
|
|
295
|
+
def constrain_table_widths(natural_widths)
|
|
296
|
+
return natural_widths if natural_widths.empty?
|
|
297
|
+
|
|
298
|
+
border_overhead = (natural_widths.length * 3) + 1
|
|
299
|
+
max_table_width = [[(@width || 80).to_i - 1, 20].max, border_overhead + natural_widths.length].max
|
|
300
|
+
available_content_width = [max_table_width - border_overhead, natural_widths.length].max
|
|
301
|
+
widths = natural_widths.map { |width| [width, 1].max }
|
|
302
|
+
return widths if widths.sum <= available_content_width
|
|
303
|
+
|
|
304
|
+
min_width = available_content_width < natural_widths.length * 3 ? 1 : 3
|
|
305
|
+
while widths.sum > available_content_width
|
|
306
|
+
index = widths.each_with_index.select { |width, _| width > min_width }.max_by(&:first)&.last
|
|
307
|
+
break unless index
|
|
308
|
+
|
|
309
|
+
widths[index] -= 1
|
|
310
|
+
end
|
|
311
|
+
widths
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# Wrap cell text to fit a given display width, splitting across newlines
|
|
315
|
+
# and wrapping long lines.
|
|
316
|
+
def wrap_table_cell(text, width)
|
|
317
|
+
width = [width.to_i, 1].max
|
|
318
|
+
text.to_s.split("\n", -1).flat_map do |line|
|
|
319
|
+
wrap_table_line(line, width)
|
|
320
|
+
end.join("\n")
|
|
168
321
|
end
|
|
169
322
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
323
|
+
# Wrap a single line of text to the given display width, preserving any
|
|
324
|
+
# ANSI escape sequences (re-emitted on each wrapped segment).
|
|
325
|
+
def wrap_table_line(line, width)
|
|
326
|
+
return [""] if line.empty?
|
|
173
327
|
|
|
174
|
-
words = text.split(' ')
|
|
175
328
|
lines = []
|
|
176
|
-
|
|
329
|
+
current = +""
|
|
330
|
+
current_width = 0
|
|
331
|
+
in_escape = false
|
|
332
|
+
escape = +""
|
|
333
|
+
|
|
334
|
+
line.each_char do |char|
|
|
335
|
+
if in_escape
|
|
336
|
+
escape << char
|
|
337
|
+
if char == "m"
|
|
338
|
+
current << escape
|
|
339
|
+
escape = +""
|
|
340
|
+
in_escape = false
|
|
341
|
+
end
|
|
342
|
+
next
|
|
343
|
+
elsif char.ord == 27
|
|
344
|
+
escape << char
|
|
345
|
+
in_escape = true
|
|
346
|
+
next
|
|
347
|
+
end
|
|
177
348
|
|
|
178
|
-
|
|
179
|
-
if
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
current_line = word
|
|
349
|
+
char_width = Unicode::DisplayWidth.of(char)
|
|
350
|
+
if current_width.positive? && current_width + char_width > width
|
|
351
|
+
lines << current
|
|
352
|
+
current = +""
|
|
353
|
+
current_width = 0
|
|
184
354
|
end
|
|
355
|
+
current << char
|
|
356
|
+
current_width += char_width
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
lines << current unless current.empty?
|
|
360
|
+
lines.empty? ? [""] : lines
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# Display width of text after stripping ANSI escape sequences, taking the
|
|
364
|
+
# maximum across lines (for multi-line cells).
|
|
365
|
+
def cell_display_width(text)
|
|
366
|
+
text.to_s.gsub(/\e\[[0-9;:]*m/, "").split("\n").map(&:display_width).max.to_i
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# ---- HTML 元素处理 ----
|
|
370
|
+
|
|
371
|
+
def convert_html_element(el)
|
|
372
|
+
tag = el.value.to_s.downcase
|
|
373
|
+
content = inline_content(el)
|
|
374
|
+
|
|
375
|
+
case tag
|
|
376
|
+
when 'del', 's', 'strike'
|
|
377
|
+
"#{AnsiCode.strikethrough}#{content}#{AnsiCode.reset}"
|
|
378
|
+
when 'ins', 'u'
|
|
379
|
+
"#{AnsiCode.underline}#{content}#{AnsiCode.reset}"
|
|
380
|
+
when 'sub'
|
|
381
|
+
content # 终端无下标,保留文本
|
|
382
|
+
when 'sup'
|
|
383
|
+
content # 终端无上标,保留文本
|
|
384
|
+
when 'kbd'
|
|
385
|
+
"#{AnsiCode.background(:white)}#{AnsiCode.color(:black)} #{content} #{AnsiCode.reset}"
|
|
386
|
+
when 'mark'
|
|
387
|
+
"#{AnsiCode.background(:yellow)}#{AnsiCode.color(:black)}#{content}#{AnsiCode.reset}"
|
|
388
|
+
when 'details', 'summary'
|
|
389
|
+
content
|
|
390
|
+
when 'br'
|
|
391
|
+
"\n"
|
|
392
|
+
when 'hr'
|
|
393
|
+
convert_hr(nil)
|
|
394
|
+
else
|
|
395
|
+
content
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def convert_html_entity(el)
|
|
400
|
+
# kramdown 已解析 HTML 实体为 UTF-8 字符
|
|
401
|
+
el.value.to_s
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# ---- 智能引号 ----
|
|
405
|
+
|
|
406
|
+
def convert_smart_quote(el)
|
|
407
|
+
case el.value
|
|
408
|
+
when :lsquo then "'"
|
|
409
|
+
when :rsquo then "'"
|
|
410
|
+
when :ldquo then '"'
|
|
411
|
+
when :rdquo then '"'
|
|
412
|
+
else el.value.to_s
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
# ---- 原始内容 ----
|
|
417
|
+
|
|
418
|
+
def convert_raw(el)
|
|
419
|
+
# 原始 HTML,在终端中通常跳过
|
|
420
|
+
el.value.to_s.start_with?('<') ? '' : el.value.to_s
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# ---- 脚注 ----
|
|
424
|
+
|
|
425
|
+
def convert_footnote(el)
|
|
426
|
+
# 脚注内容在文档末尾自动收集
|
|
427
|
+
content = inline_content(el)
|
|
428
|
+
name = el.options[:name]
|
|
429
|
+
"#{AnsiCode.color(:magenta, true)}[^#{name}]#{AnsiCode.reset}"
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
# ---- 定义列表(kramdown 独有) ----
|
|
433
|
+
|
|
434
|
+
def convert_definition_list(el)
|
|
435
|
+
"#{convert_children(el)}\n"
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def convert_definition_term(el)
|
|
439
|
+
"#{AnsiCode.bold}#{inline_content(el)}#{AnsiCode.reset}\n"
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def convert_definition_desc(el)
|
|
443
|
+
"#{@indent_str}#{inline_content(el)}\n\n"
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# ---- 缩写(kramdown 独有) ----
|
|
447
|
+
|
|
448
|
+
def convert_abbreviation(el)
|
|
449
|
+
title = el.attr['title']
|
|
450
|
+
text = inline_content(el)
|
|
451
|
+
if title && !title.empty?
|
|
452
|
+
"#{AnsiCode.underline}#{text}#{AnsiCode.reset}#{AnsiCode.color(:black, true)}(#{title})#{AnsiCode.reset}"
|
|
453
|
+
else
|
|
454
|
+
text
|
|
185
455
|
end
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
# ---- 数学公式(kramdown 独有,需 math engine) ----
|
|
186
459
|
|
|
187
|
-
|
|
188
|
-
|
|
460
|
+
def convert_math(el)
|
|
461
|
+
# 在终端中显示原始 LaTeX 公式
|
|
462
|
+
mode = el.options[:category] == :block ? 'block' : 'inline'
|
|
463
|
+
formula = el.value.to_s.strip
|
|
464
|
+
if mode == 'block'
|
|
465
|
+
"#{AnsiCode.color(:magenta, true)}[Math]\n#{formula}\n[/Math]#{AnsiCode.reset}\n\n"
|
|
466
|
+
else
|
|
467
|
+
"#{AnsiCode.color(:magenta, true)}[Math: #{formula}]#{AnsiCode.reset}"
|
|
468
|
+
end
|
|
189
469
|
end
|
|
190
470
|
|
|
471
|
+
# ---- 辅助 ----
|
|
472
|
+
|
|
191
473
|
def indent_lines(text)
|
|
192
|
-
text.split("\n").map { |line| "#{@
|
|
474
|
+
text.split("\n").map { |line| "#{@indent_str}#{line}" }.join("\n")
|
|
193
475
|
end
|
|
194
476
|
|
|
195
|
-
def
|
|
196
|
-
|
|
477
|
+
def strip_ansi_reset(text)
|
|
478
|
+
# 去掉末尾 AnsiCode.reset,让 blockquote 统一添加
|
|
479
|
+
text.gsub(/\e\[0m$/, '')
|
|
197
480
|
end
|
|
198
481
|
end
|
|
199
482
|
|
|
483
|
+
# ---- 公开 API ----
|
|
484
|
+
|
|
485
|
+
# 渲染 Markdown 文本为 ANSI 终端输出
|
|
486
|
+
#
|
|
487
|
+
# @param markdown_text [String] 输入的 Markdown 文本
|
|
488
|
+
# @param options [Hash] 渲染选项
|
|
489
|
+
# @option options [Integer] :width 终端宽度(默认 80)
|
|
490
|
+
# @option options [String] :indent 缩进字符串(默认 ' ')
|
|
491
|
+
# @option options [Symbol] :table_border_style 表格边框样式 (:none, :simple, :full)
|
|
492
|
+
# @option options [Hash] :kramdown 传递给 Kramdown::Document 的额外选项
|
|
493
|
+
#
|
|
494
|
+
# @return [String] ANSI 格式的终端输出
|
|
200
495
|
def self.render(markdown_text, options = {})
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
496
|
+
converter_options = {
|
|
497
|
+
width: options[:width] || 80,
|
|
498
|
+
indent: options[:indent] || ' ',
|
|
499
|
+
table_border_style: options[:table_border_style] || :simple
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
kramdown_opts = {
|
|
503
|
+
input: 'GFM', # GitHub Flavored Markdown
|
|
504
|
+
syntax_highlighter: nil, # 自行处理语法高亮
|
|
505
|
+
hard_wrap: false,
|
|
506
|
+
html_to_native: true,
|
|
507
|
+
line_width: converter_options[:width]
|
|
508
|
+
}.merge(options[:kramdown] || {})
|
|
509
|
+
|
|
510
|
+
doc = Kramdown::Document.new(markdown_text, kramdown_opts)
|
|
511
|
+
result, _warnings = TerminalConverter.convert(doc.root, converter_options)
|
|
512
|
+
result
|
|
211
513
|
end
|
|
212
514
|
|
|
213
515
|
def initialize(options = {})
|
|
@@ -218,4 +520,4 @@ module RubyRich
|
|
|
218
520
|
self.class.render(markdown_text, @options)
|
|
219
521
|
end
|
|
220
522
|
end
|
|
221
|
-
end
|
|
523
|
+
end
|
data/lib/ruby_rich/transcript.rb
CHANGED
|
@@ -412,6 +412,8 @@ module RubyRich
|
|
|
412
412
|
private
|
|
413
413
|
|
|
414
414
|
def render_entry(entry, index)
|
|
415
|
+
return entry.content.to_s.split("\n", -1) if entry.metadata[:plain]
|
|
416
|
+
|
|
415
417
|
case entry.type
|
|
416
418
|
when :user
|
|
417
419
|
render_plain_message(entry.content, first_prefix: "#{AnsiCode.color(:blue, true)}●#{AnsiCode.reset} ", rest_prefix: " ")
|
data/lib/ruby_rich/version.rb
CHANGED
data/lib/ruby_rich/viewport.rb
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "base64"
|
|
4
|
+
|
|
3
5
|
module RubyRich
|
|
4
6
|
class Viewport
|
|
5
7
|
attr_accessor :width, :height, :scroll_top
|
|
6
8
|
attr_reader :content, :selected_text
|
|
7
9
|
|
|
8
|
-
|
|
10
|
+
# drag_mode controls what left-click drag does in the content area:
|
|
11
|
+
# :viewport – drag scrolls the viewport (default, backward-compatible)
|
|
12
|
+
# :selection – drag selects text
|
|
13
|
+
DRAG_MODES = [:viewport, :selection].freeze
|
|
14
|
+
|
|
15
|
+
def initialize(content = "",
|
|
16
|
+
scrollbar: true, auto_scroll: false,
|
|
17
|
+
scrollbar_style: :blue, auto_copy: true,
|
|
18
|
+
drag_mode: :viewport)
|
|
9
19
|
@content = content
|
|
10
20
|
@scrollbar = scrollbar
|
|
11
21
|
@auto_scroll = auto_scroll
|
|
@@ -22,6 +32,8 @@ module RubyRich
|
|
|
22
32
|
@selection_end = nil
|
|
23
33
|
@selected_text = ""
|
|
24
34
|
@focused = true
|
|
35
|
+
@auto_copy = auto_copy
|
|
36
|
+
@drag_mode = DRAG_MODES.include?(drag_mode) ? drag_mode : :viewport
|
|
25
37
|
@rendered_lines_cache_key = nil
|
|
26
38
|
@rendered_lines_cache = nil
|
|
27
39
|
end
|
|
@@ -88,10 +100,14 @@ module RubyRich
|
|
|
88
100
|
true
|
|
89
101
|
when :mouse_down
|
|
90
102
|
return copy_selection if event_data[:button] == :right
|
|
91
|
-
|
|
92
|
-
|
|
103
|
+
if @drag_mode == :selection
|
|
104
|
+
start_scrollbar_drag(event_data, layout) || start_selection(event_data, layout)
|
|
105
|
+
else
|
|
106
|
+
start_scrollbar_drag(event_data, layout) || start_viewport_drag(event_data, layout)
|
|
107
|
+
end
|
|
93
108
|
when :mouse_drag
|
|
94
|
-
drag_scrollbar(event_data, layout) ||
|
|
109
|
+
drag_scrollbar(event_data, layout) ||
|
|
110
|
+
(@drag_mode == :selection ? drag_selection(event_data, layout) : (drag_viewport(event_data, layout) || drag_selection(event_data, layout)))
|
|
95
111
|
when :mouse_up
|
|
96
112
|
stop_scrollbar_drag || stop_viewport_drag || stop_selection
|
|
97
113
|
else
|
|
@@ -415,7 +431,7 @@ module RubyRich
|
|
|
415
431
|
|
|
416
432
|
@selecting = false
|
|
417
433
|
@selected_text = extract_selected_text
|
|
418
|
-
copy_selection
|
|
434
|
+
copy_selection if @auto_copy
|
|
419
435
|
true
|
|
420
436
|
end
|
|
421
437
|
|
|
@@ -477,6 +493,7 @@ module RubyRich
|
|
|
477
493
|
escape << char
|
|
478
494
|
if char == "m"
|
|
479
495
|
result << escape
|
|
496
|
+
result << AnsiCode.inverse if active
|
|
480
497
|
escape = +""
|
|
481
498
|
in_escape = false
|
|
482
499
|
end
|
|
@@ -531,18 +548,61 @@ module RubyRich
|
|
|
531
548
|
text.gsub(/\e\[[0-9;:]*m/, "")
|
|
532
549
|
end
|
|
533
550
|
|
|
551
|
+
# Copy text to the system clipboard, trying every available method in
|
|
552
|
+
# sequence and falling back to the OSC 52 terminal clipboard so that
|
|
553
|
+
# remote (SSH) sessions also work.
|
|
534
554
|
def copy_to_clipboard(text)
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
555
|
+
text = text.to_s
|
|
556
|
+
return false if text.empty?
|
|
557
|
+
return true if RubyRich::Terminal.windows? && try_windows_clipboard(text)
|
|
558
|
+
|
|
559
|
+
clipboard_commands.each do |command|
|
|
560
|
+
return true if write_clipboard_command(command, text)
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
copy_to_terminal_clipboard(text)
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
# Ordered list of [command, *args] arrays to try for clipboard access.
|
|
567
|
+
def clipboard_commands
|
|
568
|
+
commands = []
|
|
569
|
+
commands << ["wl-copy"] if ENV["WAYLAND_DISPLAY"]
|
|
570
|
+
if ENV["DISPLAY"]
|
|
571
|
+
commands << ["xclip", "-selection", "clipboard"]
|
|
572
|
+
commands << ["xsel", "--clipboard", "--input"]
|
|
543
573
|
end
|
|
574
|
+
commands << ["pbcopy"] if RUBY_PLATFORM.match?(/darwin/)
|
|
575
|
+
commands
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
# Attempt to write text to a given clipboard command via a pipe.
|
|
579
|
+
# Returns true when the subprocess exits successfully.
|
|
580
|
+
def write_clipboard_command(command, text)
|
|
581
|
+
IO.popen(command, "w") { |io| io.write(text) }
|
|
582
|
+
$?&.success? == true
|
|
544
583
|
rescue IOError, SystemCallError
|
|
545
|
-
|
|
584
|
+
false
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
# Try the OS-specific Windows clipboard helper and return true when it
|
|
588
|
+
# succeeds (any IO/Syscall error is treated as failure).
|
|
589
|
+
def try_windows_clipboard(text)
|
|
590
|
+
copy_to_windows_clipboard(text)
|
|
591
|
+
true
|
|
592
|
+
rescue IOError, SystemCallError
|
|
593
|
+
false
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
# OSC 52 terminal clipboard – the only fallback that works over SSH.
|
|
597
|
+
# Encoded payload is emitted on stdout for the hosting terminal to
|
|
598
|
+
# capture.
|
|
599
|
+
def copy_to_terminal_clipboard(text)
|
|
600
|
+
encoded = Base64.strict_encode64(text.encode(Encoding::UTF_8))
|
|
601
|
+
$stdout.print("\e]52;c;#{encoded}\a")
|
|
602
|
+
$stdout.flush
|
|
603
|
+
true
|
|
604
|
+
rescue Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError, IOError, SystemCallError
|
|
605
|
+
false
|
|
546
606
|
end
|
|
547
607
|
|
|
548
608
|
def copy_to_windows_clipboard(text)
|
|
@@ -559,4 +619,4 @@ module RubyRich
|
|
|
559
619
|
end
|
|
560
620
|
end
|
|
561
621
|
end
|
|
562
|
-
end
|
|
622
|
+
end
|
data/lib/ruby_rich.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ruby_rich
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.4.
|
|
4
|
+
version: 0.4.9
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- zhuang biaowei
|
|
@@ -86,19 +86,33 @@ dependencies:
|
|
|
86
86
|
- !ruby/object:Gem::Version
|
|
87
87
|
version: 0.8.2
|
|
88
88
|
- !ruby/object:Gem::Dependency
|
|
89
|
-
name:
|
|
89
|
+
name: kramdown
|
|
90
90
|
requirement: !ruby/object:Gem::Requirement
|
|
91
91
|
requirements:
|
|
92
92
|
- - "~>"
|
|
93
93
|
- !ruby/object:Gem::Version
|
|
94
|
-
version:
|
|
94
|
+
version: '2.4'
|
|
95
95
|
type: :runtime
|
|
96
96
|
prerelease: false
|
|
97
97
|
version_requirements: !ruby/object:Gem::Requirement
|
|
98
98
|
requirements:
|
|
99
99
|
- - "~>"
|
|
100
100
|
- !ruby/object:Gem::Version
|
|
101
|
-
version:
|
|
101
|
+
version: '2.4'
|
|
102
|
+
- !ruby/object:Gem::Dependency
|
|
103
|
+
name: kramdown-parser-gfm
|
|
104
|
+
requirement: !ruby/object:Gem::Requirement
|
|
105
|
+
requirements:
|
|
106
|
+
- - "~>"
|
|
107
|
+
- !ruby/object:Gem::Version
|
|
108
|
+
version: '1.1'
|
|
109
|
+
type: :runtime
|
|
110
|
+
prerelease: false
|
|
111
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
112
|
+
requirements:
|
|
113
|
+
- - "~>"
|
|
114
|
+
- !ruby/object:Gem::Version
|
|
115
|
+
version: '1.1'
|
|
102
116
|
- !ruby/object:Gem::Dependency
|
|
103
117
|
name: unicode-display_width
|
|
104
118
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -176,7 +190,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
176
190
|
- !ruby/object:Gem::Version
|
|
177
191
|
version: '0'
|
|
178
192
|
requirements: []
|
|
179
|
-
rubygems_version: 4.0.
|
|
193
|
+
rubygems_version: 4.0.13
|
|
180
194
|
specification_version: 4
|
|
181
195
|
summary: Rich text formatting and console output for Ruby
|
|
182
196
|
test_files: []
|